This commit is contained in:
2024-11-27 21:34:07 +02:00
parent 638bcba894
commit b6d1215999
190 changed files with 31518 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
github: clue
custom: https://clue.engineering/support

373
vendor/clue/framework-x/CHANGELOG.md vendored Normal file
View File

@@ -0,0 +1,373 @@
# Changelog
## 0.16.0 (2024-03-05)
We are thrilled to announce the official release of `v0.16.0` to the public! 🎉🚀
Additionally, we are making all previous tagged versions available to simplify the upgrade process.
In addition to the release of `v0.16.0`, this update includes all prior tagged releases.
This release includes exciting new features such as improved performance, additional options
for access logging, updates to our documentation and nginx + Apache configurations,
as well as many more internal improvements to our test suite and integration tests.
* Feature: Improve performance by skipping `AccessLogHandler` if it writes to `/dev/null`.
(#248 by @clue)
* Feature: Add optional `$path` argument for `AccessLogHandler`.
(#247 by @clue)
* Minor documentation improvements and update nginx + Apache configuration.
(#245 and #251 by @clue)
* Improve test suite with improved directory structure for integration tests.
(#250 by @clue)
## 0.15.0 (2023-12-07)
* Feature: Full PHP 8.3 compatibility.
(#244 by @clue)
* Feature: Add `App::__invoke()` method to enable custom integrations.
(#236 by @clue)
* Feature: Improve performance by only using `FiberHandler` for `ReactiveHandler`.
(#237 by @clue)
* Minor documentation improvements.
(#242 by @yadaiio)
## 0.14.0 (2023-07-31)
* Feature: Improve Promise v3 support and use Promise v3 template types.
(#233 and #235 by @clue)
* Feature: Improve handling `OPTIONS *` requests.
(#226 by @clue)
* Refactor logging into new `LogStreamHandler` and reactive server logic into new `ReactiveHandler`.
(#222 and #224 by @clue)
* Improve test suite and ensure 100% code coverage.
(#217, #221, #225 and #228 by @clue)
## 0.13.0 (2023-02-22)
* Feature: Forward compatibility with upcoming Promise v3.
(#188 by @clue)
* Feature: Full PHP 8.2 compatibility.
(#194 and #207 by @clue)
* Feature: Load environment variables from `$_ENV`, `$_SERVER` and `getenv()`.
(#205 by @clue)
* Feature: Update to support `Content-Length` response header on `HEAD` requests.
(#186 by @clue)
* Feature / Fix: Consistent handling for HTTP responses with multiple header values (PHP SAPI).
(#214 by @pfk84)
* Fix: Respect explicit response status code when Location response header is given (PHP SAPI).
(#191 by @jkrzefski)
* Minor documentation improvements.
(#189 by @clue)
* Add PHPStan to test environment on level `max` and improve type definitions.
(#200, #201 and #204 by @clue)
* Improve test suite and report failed assertions.
(#199 by @clue and #208 by @SimonFrings)
## 0.12.0 (2022-08-03)
* Feature: Support loading environment variables from DI container configuration.
(#184 by @clue)
* Feature: Support typed container variables for container factory functions.
(#178, #179 and #180 by @clue)
* Feature: Support nullable and `null` arguments and default values for DI container configuration.
(#181 and #183 by @clue)
* Feature: Support untyped and `mixed` arguments for container factory.
(#182 by @clue)
## 0.11.0 (2022-07-26)
* Feature: Make `AccessLogHandler` and `ErrorHandler` part of public API.
(#173 and #174 by @clue)
```php
<?php
require __DIR__ . '/../vendor/autoload.php';
$app = new FrameworkX\App(
new FrameworkX\AccessLogHandler(),
new FrameworkX\ErrorHandler()
);
// Register routes here, see routing…
$app->run();
```
* Feature: Support loading `AccessLogHandler` and `ErrorHandler` from `Container`.
(#175 by @clue)
* Feature: Read `$remote_addr` attribute for `AccessLogHandler` (trusted proxies).
(#177 by @clue)
* Internal refactoring to move all handlers to `Io` namespace.
(#176 by @clue)
* Update test suite to remove deprecated `utf8_decode()` (PHP 8.2 preparation).
(#171 by SimonFrings)
## 0.10.0 (2022-07-14)
* Feature: Built-in support for fibers on PHP 8.1+ with stable reactphp/async.
(#168 by @clue)
```php
$app->get('/book/{isbn}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db) {
$isbn = $request->getAttribute('isbn');
$result = await($db->query(
'SELECT title FROM book WHERE isbn = ?',
[$isbn]
));
assert($result instanceof React\MySQL\QueryResult);
$data = $result->resultRows[0]['title'];
return React\Http\Message\Response::plaintext(
$data
);
});
```
* Feature: Support PSR-11 container interface by using DI container as adapter.
(#163 by @clue)
* Minor documentation improvements.
(#158 by @clue and #160 by @SimonFrings)
## 0.9.0 (2022-05-13)
* Feature: Add signal handling support for `SIGINT` and `SIGTERM`.
(#150 by @clue)
* Feature: Improve error output for exception messages with special characters.
(#131 by @clue)
* Add new documentation chapters for Docker containers and HTTP redirecting.
(#138 by SimonFrings and #136, #151 and #156 by @clue)
* Minor documentation improvements.
(#143 by @zf2timo, #153 by @mattschlosser and #129 and #154 by @clue)
* Improve test suite and add tests for `Dockerfile` instructions.
(#148 and #149 by @clue)
## 0.8.0 (2022-03-07)
* Feature: Automatically start new fiber for each request on PHP 8.1+.
(#117 by @clue)
* Feature: Add fiber compatibility mode for PHP < 8.1.
(#128 by @clue)
* Improve documentation and update installation instructions for react/async.
(#116 and #126 by @clue and #124, #125 and #127 by @SimonFrings)
* Improve fiber tests to avoid now unneeded `await()` calls.
(#118 by @clue)
## 0.7.0 (2022-02-05)
* Feature: Update to use HTTP status code constants and JSON/HTML response helpers.
(#114 by @clue)
```php
$app->get('/users/{name}', function (Psr\Http\Message\ServerRequestInterface $request) {
return React\Http\Message\Response::plaintext(
"Hello " . $request->getAttribute('name') . "!\n"
);
});
```
* Feature / Fix: Update to improve protocol handling for HTTP responses with no body.
(#113 by @clue)
* Minor documentation improvements.
(#112 by @SimonFrings and #115 by @netcarver)
## 0.6.0 (2021-12-20)
* Feature: Support automatic dependency injection by using class names (DI container).
(#89, #92 and #94 by @clue)
```php
<?php
require __DIR__ . '/../vendor/autoload.php';
$app = new FrameworkX\App(Acme\Todo\JsonMiddleware::class);
$app->get('/', Acme\Todo\HelloController::class);
$app->get('/users/{name}', Acme\Todo\UserController::class);
$app->run();
```
* Feature: Add support for explicit DI container configuration.
(#95, #96 and #97 by @clue)
```php
<?php
require __DIR__ . '/../vendor/autoload.php';
$container = new FrameworkX\Container([
Acme\Todo\HelloController::class => fn() => new Acme\Todo\HelloController();
Acme\Todo\UserController::class => function (React\Http\Browser $browser) {
// example UserController class requires two arguments:
// - first argument will be autowired based on class reference
// - second argument expects some manual value
return new Acme\Todo\UserController($browser, 42);
}
]);
// …
```
* Feature: Refactor to use `$_SERVER` instead of `getenv()`.
(#91 by @bpolaszek)
* Minor documentation improvements.
(#100 by @clue)
* Update test suite to use stable PHP 8.1 Docker image.
(#90 by @clue)
## 0.5.0 (2021-11-30)
* Feature / BC break: Simplify `App` by always using default loop, drop optional loop instance.
(#88 by @clue)
```php
// old
$loop = React\EventLoop\Loop::get();
$app = new FrameworkX\App($loop);
// new (already supported before)
$app = new FrameworkX\App();
```
* Add documentation for manual restart of systemd service and chapter for Caddy deployment.
(#87 by @SimonFrings and #82 by @francislavoie)
* Improve documentation, remove leftover `$loop` references and fix typos.
(#72 by @shuvroroy, #80 by @Ivanshamir, #81 by @clue and #83 by @rattuscz)
## 0.4.0 (2021-11-23)
We are excited to announce the official release of Framework X to the public! 🎉🚀
This release includes exciting new features such as full compatibility with PHP 8.1,
improvements to response handling, and enhanced documentation covering nginx,
Apache, and async database usage.
* Feature: Announce Framework X public beta.
(#64 by @clue)
* Feature: Full PHP 8.1 compatibility.
(#58 by @clue)
* Feature: Improve `AccessLogHandler` and fix response size for streaming response body.
(#47, #48, #49 and #50 by @clue)
* Feature / Fix: Skip sending body and `Content-Length` for responses with no body.
(#51 by @clue)
* Feature / Fix: Consistently reject proxy requests and handle `OPTIONS *` requests.
(#46 by @clue)
* Add new documentation chapters for nginx, Apache and async database.
(#57, #59 and #60 by @clue)
* Improve documentation, examples and describe HTTP caching and output buffering.
(#52, #53, #55, #56, #61, #62 and #63 by @clue)
## 0.3.0 (2021-09-23)
* Feature: Add support for global middleware.
(#23 by @clue)
* Feature: Improve error output and refactor internal error handler.
(#37, #39 and #41 by @clue)
* Feature: Support changing listening address via new `X_LISTEN` environment variable.
(#38 by @clue)
* Feature: Update to new ReactPHP HTTP and Socket API.
(#26 and #29 by @HLeithner and #34 by @clue)
* Feature: Refactor to use new `AccessLogHandler`, `RouteHandler`, `RedirectHandler` and `SapiHandler`.
(#42, #43, #44 and #45 by @clue)
* Fix: Fix path filter regex.
(#27 by @HLeithner)
* Add documentation for async middleware and systemd service unit configuration.
(#24 by @Degra1991 and #32, #35, #36 and #40 by @clue)
* Improve test suite and run tests on Windows with PHPUnit.
(#31 by @SimonFrings and #28 and #33 by @clue)
## 0.2.0 (2021-06-18)
* Feature: Simplify `App` usage by making `LoopInterface` argument optional.
(#22 by @clue)
```php
// old (still supported)
$loop = React\EventLoop\Factory::create();
$app = new FrameworkX\App($loop);
// new (using default loop)
$app = new FrameworkX\App();
```
* Feature: Add middleware support.
(#18 by @clue)
* Feature: Refactor and simplify route dispatcher.
(#21 by @clue)
* Feature: Add Generator-based coroutine implementation.
(#17 by @clue)
* Minor documentation improvements.
(#15, #16 and #19 by @clue)
## 0.1.0 (2021-04-30)
We're excited to announce the release of the first version of Framework X in
private beta! This version marks the starting point of our project and is the
first of many milestones for making async PHP easier than ever before.
* Release Framework X, major documentation overhaul and improve examples.
(#14, #13 and #2 by @clue)
* Feature: Support running behind nginx and Apache (PHP-FPM and mod_php).
(#3, #11 and #12 by @clue)
* Feature / Fix: Consistently parse request URI and improve URL handling.
(#4, #5, #6 and #7 by @clue)
* Feature: Rewrite `FilesystemHandler`, improve file access and directory listing.
(#8 and #9 by @clue)
* Feature: Add `any()` router method to match any request method.
(#10 by @clue)

21
vendor/clue/framework-x/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019 Christian Lück
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

147
vendor/clue/framework-x/README.md vendored Normal file
View File

@@ -0,0 +1,147 @@
# Framework X
[![CI status](https://github.com/clue-access/framework-x/workflows/CI/badge.svg)](https://github.com/clue-access/framework-x/actions)
[![code coverage](https://img.shields.io/badge/code%20coverage-100%25-success)](#tests)
Framework X the simple and fast micro framework for building reactive web applications that run anywhere.
* [Support us](#support-us)
* [Quickstart](#quickstart)
* [Documentation](#documentation)
* [Contribute](#contribute)
* [Tests](#tests)
* [License](#license)
## Support us
We invest a lot of time developing, maintaining and updating our awesome
open-source projects. You can help us sustain this high-quality of our work by
[becoming a sponsor on GitHub](https://github.com/sponsors/clue). Sponsors get
numerous benefits in return, see our [sponsoring page](https://github.com/sponsors/clue)
for details.
Let's take these projects to the next level together! 🚀
## Quickstart
Start by creating an empty project directory.
Next, we can start by taking a look at a simple example application.
You can use this example to get started by creating a new `public/` directory with
an `index.php` file inside:
```php
<?php
require __DIR__ . '/../vendor/autoload.php';
$app = new FrameworkX\App();
$app->get('/', function () {
return React\Http\Message\Response::plaintext(
"Hello wörld!\n"
);
});
$app->get('/users/{name}', function (Psr\Http\Message\ServerRequestInterface $request) {
return React\Http\Message\Response::plaintext(
"Hello " . $request->getAttribute('name') . "!\n"
);
});
$app->run();
```
Next, we need to install X and its dependencies to actually run this project.
In your project directory, simply run the following command:
```bash
$ composer require clue/framework-x:^0.16
```
> See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades.
That's it already! The next step is now to serve this web application.
One of the nice properties of this project is that is works both behind
traditional web server setups as well as in a stand-alone environment.
For example, you can run the above example using the built-in web server like
this:
```bash
$ php public/index.php
```
You can now use your favorite web browser or command line tool to check your web
application responds as expected:
```bash
$ curl http://localhost:8080/
Hello wörld!
```
## Documentation
Hooked?
See [website](https://framework-x.org/) for full documentation.
Found a typo or want to contribute?
The website documentation is built from the source documentation files in
the [docs/](docs/) folder.
## Contribute
You want to contribute to the Framework X source code or documentation? You've
come to the right place!
To contribute to the source code just locate the [src/](src/) folder and you'll find all
content in there. Additionally, our [tests/](tests/) folder contains all our unit
tests and acceptance tests to assure our code works as expected. For more
information on how to run the test suite check out our [testing chapter](#tests).
If you want to contribute to the [documentation](#documentation) of Framework X
found on the website, take a look inside the [docs/](docs/) folder. You'll find further
instructions inside the `README.md` in there.
Found a typo on our [website](https://framework-x.org/)? Simply go to our
[website repository](https://github.com/clue/framework-x-website)
and follow the instructions found in the `README`.
## Tests
To run the test suite, you first need to clone this repo and then install all
dependencies [through Composer](https://getcomposer.org/):
```bash
$ composer install
```
To run the test suite, go to the project root and run:
```bash
$ vendor/bin/phpunit
```
The test suite is set up to always ensure 100% code coverage across all
supported environments. If you have the Xdebug extension installed, you can also
generate a code coverage report locally like this:
```bash
$ XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
```
Additionally, you can run our sophisticated integration tests to verify the
framework examples work as expected behind your web server. Use your web server
of choice (see deployment documentation) and execute the tests with the URL to
your installation like this:
```bash
$ php tests/integration/public/index.php
$ tests/integration.bash http://localhost:8080
```
## License
This project is released under the permissive [MIT license](LICENSE).
> Did you know that I offer custom development services and issuing invoices for
sponsorships of releases and for contributions? Contact me (@clue) for details.

40
vendor/clue/framework-x/composer.json vendored Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "clue/framework-x",
"description": "Framework X the simple and fast micro framework for building reactive web applications that run anywhere.",
"keywords": ["microframework", "micro", "framework", "web", "http", "event-driven", "async", "ReactPHP"],
"homepage": "https://framework-x.org/",
"license": "MIT",
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering"
}
],
"require": {
"php": ">=7.1",
"nikic/fast-route": "^1.3",
"react/async": "^4 || ^3",
"react/http": "^1.9",
"react/promise": "^3 || ^2.10",
"react/socket": "^1.13"
},
"require-dev": {
"phpstan/phpstan": "1.10.47 || 1.4.10",
"phpunit/phpunit": "^9.6 || ^7.5",
"psr/container": "^2 || ^1",
"react/promise-timer": "^1.10"
},
"autoload": {
"psr-4": {
"FrameworkX\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"FrameworkX\\Tests\\": "tests/"
},
"files": [
"tests/FiberStub.php"
]
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace FrameworkX;
use FrameworkX\Io\LogStreamHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
use React\Promise\PromiseInterface;
use React\Stream\ReadableStreamInterface;
/**
* @final
*/
class AccessLogHandler
{
/** @var ?LogStreamHandler */
private $logger;
/** @var bool */
private $hasHighResolution;
/**
* @param ?string $path (optional) absolute log file path or will log to console output by default
* @throws \InvalidArgumentException if given `$path` is not an absolute file path
* @throws \RuntimeException if given `$path` can not be opened in append mode
*/
public function __construct(?string $path = null)
{
if ($path === null) {
$path = \PHP_SAPI === 'cli' ? 'php://output' : 'php://stderr';
}
$logger = new LogStreamHandler($path);
if (!$logger->isDevNull()) {
// only assign logger if we're not logging to /dev/null (which would discard any logs)
$this->logger = $logger;
}
$this->hasHighResolution = \function_exists('hrtime'); // PHP 7.3+
}
/**
* [Internal] Returns whether we're writing to /dev/null (which will discard any logs)
*
* @internal
* @return bool
*/
public function isDevNull(): bool
{
return $this->logger === null;
}
/**
* @return ResponseInterface|PromiseInterface<ResponseInterface>|\Generator
*/
public function __invoke(ServerRequestInterface $request, callable $next)
{
if ($this->logger === null) {
// Skip if we're logging to /dev/null (which will discard any logs).
// As an additional optimization, the `App` will automatically
// detect we no longer need to invoke this instance at all.
return $next($request); // @codeCoverageIgnore
}
$now = $this->now();
$response = $next($request);
if ($response instanceof PromiseInterface) {
/** @var PromiseInterface<ResponseInterface> $response */
return $response->then(function (ResponseInterface $response) use ($request, $now) {
$this->logWhenClosed($request, $response, $now);
return $response;
});
} elseif ($response instanceof \Generator) {
return (function (\Generator $generator) use ($request, $now) {
$response = yield from $generator;
$this->logWhenClosed($request, $response, $now);
return $response;
})($response);
} else {
$this->logWhenClosed($request, $response, $now);
return $response;
}
}
/**
* checks if response body is closed (not streaming) before writing log message for response
*/
private function logWhenClosed(ServerRequestInterface $request, ResponseInterface $response, float $start): void
{
$body = $response->getBody();
if ($body instanceof ReadableStreamInterface && $body->isReadable()) {
$size = 0;
$body->on('data', function (string $chunk) use (&$size) {
$size += strlen($chunk);
});
$body->on('close', function () use (&$size, $request, $response, $start) {
$this->log($request, $response, $size, $this->now() - $start);
});
} else {
$this->log($request, $response, $body->getSize() ?? strlen((string) $body), $this->now() - $start);
}
}
/**
* writes log message for response after response body is closed (not streaming anymore)
*/
private function log(ServerRequestInterface $request, ResponseInterface $response, int $responseSize, float $time): void
{
$method = $request->getMethod();
$status = $response->getStatusCode();
// HEAD requests and `204 No Content` and `304 Not Modified` always use an empty response body
if ($method === 'HEAD' || $status === Response::STATUS_NO_CONTENT || $status === Response::STATUS_NOT_MODIFIED) {
$responseSize = 0;
}
\assert($this->logger instanceof LogStreamHandler);
$this->logger->log(
($request->getAttribute('remote_addr') ?? $request->getServerParams()['REMOTE_ADDR'] ?? '-') . ' ' .
'"' . $this->escape($method) . ' ' . $this->escape($request->getRequestTarget()) . ' HTTP/' . $request->getProtocolVersion() . '" ' .
$status . ' ' . $responseSize . ' ' . sprintf('%.3F', $time < 0 ? 0 : $time)
);
}
private function escape(string $s): string
{
return (string) preg_replace_callback('/[\x00-\x1F\x7F-\xFF"\\\\]+/', function (array $m) {
return str_replace('%', '\x', rawurlencode($m[0]));
}, $s);
}
private function now(): float
{
return $this->hasHighResolution ? hrtime(true) * 1e-9 : microtime(true);
}
}

354
vendor/clue/framework-x/src/App.php vendored Normal file
View File

@@ -0,0 +1,354 @@
<?php
namespace FrameworkX;
use FrameworkX\Io\MiddlewareHandler;
use FrameworkX\Io\ReactiveHandler;
use FrameworkX\Io\RedirectHandler;
use FrameworkX\Io\RouteHandler;
use FrameworkX\Io\SapiHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use function React\Async\await;
class App
{
/** @var MiddlewareHandler */
private $handler;
/** @var RouteHandler */
private $router;
/** @var ReactiveHandler|SapiHandler */
private $sapi;
/**
* Instantiate new X application
*
* ```php
* // instantiate
* $app = new App();
*
* // instantiate with global middleware
* $app = new App($middleware);
* $app = new App($middleware1, $middleware2);
* ```
*
* @param callable|class-string ...$middleware
*/
public function __construct(...$middleware)
{
// new MiddlewareHandler([$fiberHandler, $accessLogHandler, $errorHandler, ...$middleware, $routeHandler])
$handlers = [];
$container = $needsErrorHandler = new Container();
// only log for built-in webserver and PHP development webserver by default, others have their own access log
$needsAccessLog = (\PHP_SAPI === 'cli' || \PHP_SAPI === 'cli-server') ? $container : null;
if ($middleware) {
$needsErrorHandlerNext = false;
foreach ($middleware as $handler) {
// load AccessLogHandler and ErrorHandler instance from last Container
if ($handler === AccessLogHandler::class) {
$handler = $container->getAccessLogHandler();
} elseif ($handler === ErrorHandler::class) {
$handler = $container->getErrorHandler();
}
// ensure AccessLogHandler is always followed by ErrorHandler
if ($needsErrorHandlerNext && !$handler instanceof ErrorHandler) {
break;
}
$needsErrorHandlerNext = false;
if ($handler instanceof Container) {
// remember last Container to load any following class names
$container = $handler;
// add default ErrorHandler from last Container before adding any other handlers, may be followed by other Container instances (unlikely)
if (!$handlers) {
$needsErrorHandler = $needsAccessLog = $container;
}
} elseif (!\is_callable($handler)) {
$handlers[] = $container->callable($handler);
} else {
// don't need a default ErrorHandler if we're adding one as first handler or AccessLogHandler as first followed by one
if ($needsErrorHandler && ($handler instanceof ErrorHandler || $handler instanceof AccessLogHandler) && !$handlers) {
$needsErrorHandler = null;
}
// only add to list of handlers if this is not a NOOP
if (!$handler instanceof AccessLogHandler || !$handler->isDevNull()) {
$handlers[] = $handler;
}
if ($handler instanceof AccessLogHandler) {
$needsAccessLog = null;
$needsErrorHandlerNext = true;
}
}
}
if ($needsErrorHandlerNext) {
throw new \TypeError('AccessLogHandler must be followed by ErrorHandler');
}
}
// add default ErrorHandler as first handler unless it is already added explicitly
if ($needsErrorHandler instanceof Container) {
\array_unshift($handlers, $needsErrorHandler->getErrorHandler());
}
// only log for built-in webserver and PHP development webserver by default, others have their own access log
if ($needsAccessLog instanceof Container) {
$handler = $needsAccessLog->getAccessLogHandler();
if (!$handler->isDevNull()) {
\array_unshift($handlers, $handler);
}
}
$this->router = new RouteHandler($container);
$handlers[] = $this->router;
$this->handler = new MiddlewareHandler($handlers);
$this->sapi = \PHP_SAPI === 'cli' ? new ReactiveHandler($container->getEnv('X_LISTEN')) : new SapiHandler();
}
/**
* @param string $route
* @param callable|class-string $handler
* @param callable|class-string ...$handlers
*/
public function get(string $route, $handler, ...$handlers): void
{
$this->map(['GET'], $route, $handler, ...$handlers);
}
/**
* @param string $route
* @param callable|class-string $handler
* @param callable|class-string ...$handlers
*/
public function head(string $route, $handler, ...$handlers): void
{
$this->map(['HEAD'], $route, $handler, ...$handlers);
}
/**
* @param string $route
* @param callable|class-string $handler
* @param callable|class-string ...$handlers
*/
public function post(string $route, $handler, ...$handlers): void
{
$this->map(['POST'], $route, $handler, ...$handlers);
}
/**
* @param string $route
* @param callable|class-string $handler
* @param callable|class-string ...$handlers
*/
public function put(string $route, $handler, ...$handlers): void
{
$this->map(['PUT'], $route, $handler, ...$handlers);
}
/**
* @param string $route
* @param callable|class-string $handler
* @param callable|class-string ...$handlers
*/
public function patch(string $route, $handler, ...$handlers): void
{
$this->map(['PATCH'], $route, $handler, ...$handlers);
}
/**
* @param string $route
* @param callable|class-string $handler
* @param callable|class-string ...$handlers
*/
public function delete(string $route, $handler, ...$handlers): void
{
$this->map(['DELETE'], $route, $handler, ...$handlers);
}
/**
* @param string $route
* @param callable|class-string $handler
* @param callable|class-string ...$handlers
*/
public function options(string $route, $handler, ...$handlers): void
{
// backward compatibility: `OPTIONS * HTTP/1.1` can be matched with empty path (legacy)
if ($route === '') {
$route = '*';
}
$this->map(['OPTIONS'], $route, $handler, ...$handlers);
}
/**
* @param string $route
* @param callable|class-string $handler
* @param callable|class-string ...$handlers
*/
public function any(string $route, $handler, ...$handlers): void
{
$this->map(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], $route, $handler, ...$handlers);
}
/**
*
* @param string[] $methods
* @param string $route
* @param callable|class-string $handler
* @param callable|class-string ...$handlers
*/
public function map(array $methods, string $route, $handler, ...$handlers): void
{
$this->router->map($methods, $route, $handler, ...$handlers);
}
/**
* @param string $route
* @param string $target
* @param int $code
*/
public function redirect(string $route, string $target, int $code = Response::STATUS_FOUND): void
{
$this->any($route, new RedirectHandler($target, $code));
}
/**
* Runs the app to handle HTTP requests according to any registered routes and middleware.
*
* This is where the magic happens: When executed on the command line (CLI),
* this will run the powerful reactive request handler built on top of
* ReactPHP. This works by running the efficient built-in HTTP web server to
* handle incoming HTTP requests through ReactPHP's HTTP and socket server.
* This async execution mode is usually recommended as it can efficiently
* process a large number of concurrent connections and process multiple
* incoming requests simultaneously. The long-running server process will
* continue to run until it is interrupted by a signal.
*
* When executed behind traditional PHP SAPIs (PHP-FPM, FastCGI, Apache, etc.),
* this will handle a single request and run until a single response is sent.
* This is particularly useful because it allows you to run the exact same
* app in any environment.
*
* @see ReactiveHandler::run()
* @see SapiHandler::run()
*/
public function run(): void
{
$this->sapi->run(\Closure::fromCallable([$this, 'handleRequest']));
}
/**
* Invokes the app to handle a single HTTP request according to any registered routes and middleware.
*
* This method allows you to pass in a single HTTP request object that will
* be processed according to any registered routes and middleware and will
* return an HTTP response object as a result.
*
* ```php
* $app = new FrameworkX\App();
* $app->get('/', fn() => React\Http\Message\Response::plaintext("Hello!\n"));
*
* $request = new React\Http\Message\ServerRequest('GET', 'https://example.com/');
* $response = $app($request);
*
* assert($response instanceof Psr\Http\Message\ResponseInterface);
* assert($response->getStatusCode() === 200);
* assert($response->getBody()->getContents() === "Hello\n");
* ```
*
* This is particularly useful for higher-level integration test suites and
* for custom integrations with other runtime environments like serverless
* functions or other frameworks. Otherwise, most applications would likely
* want to use the `run()` method to run the application and automatically
* accept incoming HTTP requests according to the PHP SAPI in use.
*
* @param ServerRequestInterface $request The HTTP request object to process.
* @return ResponseInterface This method returns an HTTP response object
* according to any registered routes and middleware. If any handler is
* async, it will await its execution before returning, running the
* event loop as needed. If the request can not be routed or any handler
* fails, it will return a matching HTTP error response object.
* @throws void This method never throws. If the request can not be routed
* or any handler fails, it will be turned into a valid error response
* before returning.
* @see self::run()
*/
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
$response = $this->handleRequest($request);
if ($response instanceof PromiseInterface) {
/** @throws void */
$response = await($response);
assert($response instanceof ResponseInterface);
}
return $response;
}
/**
* @param ServerRequestInterface $request
* @return ResponseInterface|PromiseInterface<ResponseInterface>
* Returns a response or a Promise which eventually fulfills with a
* response. This method never throws or resolves a rejected promise.
* If the request can not be routed or the handler fails, it will be
* turned into a valid error response before returning.
* @throws void
*/
private function handleRequest(ServerRequestInterface $request)
{
$response = ($this->handler)($request);
assert($response instanceof ResponseInterface || $response instanceof PromiseInterface || $response instanceof \Generator);
if ($response instanceof \Generator) {
if ($response->valid()) {
$response = $this->coroutine($response);
} else {
$response = $response->getReturn();
assert($response instanceof ResponseInterface);
}
}
return $response;
}
/**
* @return PromiseInterface<ResponseInterface>
*/
private function coroutine(\Generator $generator): PromiseInterface
{
$next = null;
$deferred = new Deferred();
$next = function () use ($generator, &$next, $deferred) {
if (!$generator->valid()) {
$deferred->resolve($generator->getReturn());
return;
}
$promise = $generator->current();
assert($promise instanceof PromiseInterface);
$promise->then(function ($value) use ($generator, $next) {
$generator->send($value);
$next();
}, function ($reason) use ($generator, $next) {
$generator->throw($reason);
$next();
});
};
$next();
return $deferred->promise();
}
}

View File

@@ -0,0 +1,382 @@
<?php
namespace FrameworkX;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* @final
*/
class Container
{
/** @var array<string,object|callable():(object|scalar|null)|scalar|null>|ContainerInterface */
private $container;
/** @var bool */
private $useProcessEnv;
/** @param array<string,callable():(object|scalar|null) | object | scalar | null>|ContainerInterface $loader */
public function __construct($loader = [])
{
/** @var mixed $loader explicit type check for mixed if user ignores parameter type */
if (!\is_array($loader) && !$loader instanceof ContainerInterface) {
throw new \TypeError(
'Argument #1 ($loader) must be of type array|Psr\Container\ContainerInterface, ' . (\is_object($loader) ? get_class($loader) : gettype($loader)) . ' given'
);
}
foreach (($loader instanceof ContainerInterface ? [] : $loader) as $name => $value) {
if (
(!\is_object($value) && !\is_scalar($value) && $value !== null) ||
(!$value instanceof $name && !$value instanceof \Closure && !\is_string($value) && \strpos($name, '\\') !== false)
) {
throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (is_object($value) ? get_class($value) : gettype($value)));
}
}
$this->container = $loader;
// prefer reading environment from `$_ENV` and `$_SERVER`, only fall back to `getenv()` in thread-safe environments
$this->useProcessEnv = \ZEND_THREAD_SAFE === false || \in_array(\PHP_SAPI, ['cli', 'cli-server', 'cgi-fcgi', 'fpm-fcgi'], true);
}
/** @return mixed */
public function __invoke(ServerRequestInterface $request, callable $next = null)
{
if ($next === null) {
// You don't want to end up here. This only happens if you use the
// container as a final request handler instead of as a middleware.
// In this case, you should omit the container or add another final
// request handler behind the container in the middleware chain.
throw new \BadMethodCallException('Container should not be used as final request handler');
}
// If the container is used as a middleware, simply forward to the next
// request handler. As an additional optimization, the container would
// usually be filtered out from a middleware chain as this is a NO-OP.
return $next($request);
}
/**
* @param class-string $class
* @return callable(ServerRequestInterface,?callable=null)
* @internal
*/
public function callable(string $class): callable
{
return function (ServerRequestInterface $request, callable $next = null) use ($class) {
// Check `$class` references a valid class name that can be autoloaded
if (\is_array($this->container) && !\class_exists($class, true) && !interface_exists($class, false) && !trait_exists($class, false)) {
throw new \BadMethodCallException('Request handler class ' . $class . ' not found');
}
try {
if ($this->container instanceof ContainerInterface) {
$handler = $this->container->get($class);
} else {
$handler = $this->loadObject($class);
}
} catch (\Throwable $e) {
throw new \BadMethodCallException(
'Request handler class ' . $class . ' failed to load: ' . $e->getMessage(),
0,
$e
);
}
// Check `$handler` references a class name that is callable, i.e. has an `__invoke()` method.
// This initial version is intentionally limited to checking the method name only.
// A follow-up version will likely use reflection to check request handler argument types.
if (!is_callable($handler)) {
throw new \BadMethodCallException('Request handler class "' . $class . '" has no public __invoke() method');
}
// invoke request handler as middleware handler or final controller
if ($next === null) {
return $handler($request);
}
return $handler($request, $next);
};
}
/** @internal */
public function getEnv(string $name): ?string
{
assert(\preg_match('/^[A-Z][A-Z0-9_]+$/', $name) === 1);
if ($this->container instanceof ContainerInterface && $this->container->has($name)) {
$value = $this->container->get($name);
} elseif ($this->hasVariable($name)) {
$value = $this->loadVariable($name, 'mixed', true, 64);
} else {
return null;
}
if (!\is_string($value) && $value !== null) {
throw new \TypeError('Environment variable $' . $name . ' expected type string|null, but got ' . (\is_object($value) ? \get_class($value) : \gettype($value)));
}
return $value;
}
/** @internal */
public function getAccessLogHandler(): AccessLogHandler
{
if ($this->container instanceof ContainerInterface) {
if ($this->container->has(AccessLogHandler::class)) {
// @phpstan-ignore-next-line method return type will ensure correct type or throw `TypeError`
return $this->container->get(AccessLogHandler::class);
} else {
return new AccessLogHandler();
}
}
return $this->loadObject(AccessLogHandler::class);
}
/** @internal */
public function getErrorHandler(): ErrorHandler
{
if ($this->container instanceof ContainerInterface) {
if ($this->container->has(ErrorHandler::class)) {
// @phpstan-ignore-next-line method return type will ensure correct type or throw `TypeError`
return $this->container->get(ErrorHandler::class);
} else {
return new ErrorHandler();
}
}
return $this->loadObject(ErrorHandler::class);
}
/**
* @template T of object
* @param class-string<T> $name
* @return T
* @throws \BadMethodCallException if object of type $name can not be loaded
*/
private function loadObject(string $name, int $depth = 64) /*: object (PHP 7.2+) */
{
assert(\is_array($this->container));
if (\array_key_exists($name, $this->container)) {
if (\is_string($this->container[$name])) {
if ($depth < 1) {
throw new \BadMethodCallException('Factory for ' . $name . ' is recursive');
}
// @phpstan-ignore-next-line because type of container value is explicitly checked after getting here
$value = $this->loadObject($this->container[$name], $depth - 1);
if (!$value instanceof $name) {
throw new \BadMethodCallException('Factory for ' . $name . ' returned unexpected ' . \get_class($value));
}
$this->container[$name] = $value;
} elseif ($this->container[$name] instanceof \Closure) {
// build list of factory parameters based on parameter types
$closure = new \ReflectionFunction($this->container[$name]);
$params = $this->loadFunctionParams($closure, $depth, true);
// invoke factory with list of parameters
$value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params);
if (\is_string($value)) {
if ($depth < 1) {
throw new \BadMethodCallException('Factory for ' . $name . ' is recursive');
}
// @phpstan-ignore-next-line because type of container value is explicitly checked after getting here
$value = $this->loadObject($value, $depth - 1);
}
if (!$value instanceof $name) {
throw new \BadMethodCallException('Factory for ' . $name . ' returned unexpected ' . (is_object($value) ? get_class($value) : gettype($value)));
}
$this->container[$name] = $value;
} elseif (!$this->container[$name] instanceof $name) {
throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (\is_object($this->container[$name]) ? \get_class($this->container[$name]) : \gettype($this->container[$name])));
}
assert($this->container[$name] instanceof $name);
return $this->container[$name];
}
// Check `$name` references a valid class name that can be autoloaded
if (!\class_exists($name, true) && !interface_exists($name, false) && !trait_exists($name, false)) {
throw new \BadMethodCallException('Class ' . $name . ' not found');
}
$class = new \ReflectionClass($name);
if (!$class->isInstantiable()) {
$modifier = 'class';
if ($class->isInterface()) {
$modifier = 'interface';
} elseif ($class->isAbstract()) {
$modifier = 'abstract class';
} elseif ($class->isTrait()) {
$modifier = 'trait';
}
throw new \BadMethodCallException('Cannot instantiate ' . $modifier . ' '. $name);
}
// build list of constructor parameters based on parameter types
$ctor = $class->getConstructor();
$params = $ctor === null ? [] : $this->loadFunctionParams($ctor, $depth, false);
// instantiate with list of parameters
// @phpstan-ignore-next-line because `$class->newInstance()` is known to return `T`
return $this->container[$name] = $params === [] ? new $name() : $class->newInstance(...$params);
}
/**
* @return list<mixed>
* @throws \BadMethodCallException if either parameter can not be loaded
*/
private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $depth, bool $allowVariables): array
{
$params = [];
foreach ($function->getParameters() as $parameter) {
$params[] = $this->loadParameter($parameter, $depth, $allowVariables);
}
return $params;
}
/**
* @return mixed
* @throws \BadMethodCallException if $parameter can not be loaded
*/
private function loadParameter(\ReflectionParameter $parameter, int $depth, bool $allowVariables) /*: mixed (PHP 8.0+) */
{
assert(\is_array($this->container));
$type = $parameter->getType();
$hasDefault = $parameter->isDefaultValueAvailable() || ((!$type instanceof \ReflectionNamedType || $type->getName() !== 'mixed') && $parameter->allowsNull());
// abort for union types (PHP 8.0+) and intersection types (PHP 8.1+)
// @phpstan-ignore-next-line for PHP < 8
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { // @codeCoverageIgnoreStart
if ($hasDefault) {
return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
}
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type);
} // @codeCoverageIgnoreEnd
// load container variables if parameter name is known
assert($type === null || $type instanceof \ReflectionNamedType);
if ($allowVariables && $this->hasVariable($parameter->getName())) {
return $this->loadVariable($parameter->getName(), $type === null ? 'mixed' : $type->getName(), $parameter->allowsNull(), $depth);
}
// abort if parameter is untyped and not explicitly defined by container variable
if ($type === null) {
assert($parameter->allowsNull());
if ($parameter->isDefaultValueAvailable()) {
return $parameter->getDefaultValue();
}
throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type');
}
// use default/nullable argument if not loadable as container variable or by type
assert($type instanceof \ReflectionNamedType);
if ($hasDefault && ($type->isBuiltin() || !\array_key_exists($type->getName(), $this->container))) {
return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
}
// abort if required container variable is not defined or for any other primitive types (array etc.)
if ($type->isBuiltin()) {
if ($allowVariables) {
throw new \BadMethodCallException(self::parameterError($parameter) . ' is not defined');
} else {
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName());
}
}
// abort for unreasonably deep nesting or recursive types
if ($depth < 1) {
throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive');
}
// @phpstan-ignore-next-line because `$type->getName()` is a `class-string` by definition
return $this->loadObject($type->getName(), $depth - 1);
}
private function hasVariable(string $name): bool
{
return (\is_array($this->container) && \array_key_exists($name, $this->container)) || (isset($_ENV[$name]) || (\is_string($_SERVER[$name] ?? null) || ($this->useProcessEnv && \getenv($name) !== false)) && \preg_match('/^[A-Z][A-Z0-9_]+$/', $name));
}
/**
* @return object|string|int|float|bool|null
* @throws \BadMethodCallException if $name is not a valid container variable
*/
private function loadVariable(string $name, string $type, bool $nullable, int $depth) /*: object|string|int|float|bool|null (PHP 8.0+) */
{
assert($this->hasVariable($name));
assert(\is_array($this->container) || !$this->container->has($name));
if (\is_array($this->container) && ($this->container[$name] ?? null) instanceof \Closure) {
if ($depth < 1) {
throw new \BadMethodCallException('Container variable $' . $name . ' is recursive');
}
// build list of factory parameters based on parameter types
$factory = $this->container[$name];
assert($factory instanceof \Closure);
$closure = new \ReflectionFunction($factory);
$params = $this->loadFunctionParams($closure, $depth - 1, true);
// invoke factory with list of parameters
$value = $params === [] ? $factory() : $factory(...$params);
if (!\is_object($value) && !\is_scalar($value) && $value !== null) {
throw new \BadMethodCallException('Container variable $' . $name . ' expected type object|scalar|null from factory, but got ' . \gettype($value));
}
$this->container[$name] = $value;
} elseif (\is_array($this->container) && \array_key_exists($name, $this->container)) {
$value = $this->container[$name];
} elseif (isset($_ENV[$name])) {
assert(\is_string($_ENV[$name]));
$value = $_ENV[$name];
} elseif (isset($_SERVER[$name])) {
assert(\is_string($_SERVER[$name]));
$value = $_SERVER[$name];
} else {
$value = \getenv($name);
assert($this->useProcessEnv && $value !== false);
}
assert(\is_object($value) || \is_scalar($value) || $value === null);
// allow null values if parameter is marked nullable or untyped or mixed
if ($nullable && $value === null) {
return null;
}
// skip type checks and allow all values if expected type is undefined or mixed (PHP 8+)
if ($type === 'mixed') {
return $value;
}
if (
(\is_object($value) && !$value instanceof $type) ||
(!\is_object($value) && !\in_array($type, ['string', 'int', 'float', 'bool'])) ||
($type === 'string' && !\is_string($value)) || ($type === 'int' && !\is_int($value)) || ($type === 'float' && !\is_float($value)) || ($type === 'bool' && !\is_bool($value))
) {
throw new \BadMethodCallException('Container variable $' . $name . ' expected type ' . $type . ', but got ' . (\is_object($value) ? \get_class($value) : \gettype($value)));
}
return $value;
}
/** @throws void */
private static function parameterError(\ReflectionParameter $parameter): string
{
$name = $parameter->getDeclaringFunction()->getShortName();
if (!$parameter->getDeclaringFunction()->isClosure() && ($class = $parameter->getDeclaringClass()) !== null) {
$name = explode("\0", $class->getName())[0] . '::' . $name;
}
return 'Argument ' . ($parameter->getPosition() + 1) . ' ($' . $parameter->getName() . ') of ' . $name . '()';
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace FrameworkX;
use FrameworkX\Io\HtmlHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
use React\Promise\PromiseInterface;
/**
* @final
*/
class ErrorHandler
{
/** @var Htmlhandler */
private $html;
public function __construct()
{
$this->html = new HtmlHandler();
}
/**
* @return ResponseInterface|PromiseInterface<ResponseInterface>|\Generator
* Returns a response, a Promise which eventually fulfills with a
* response or a Generator which eventually returns a response. This
* method never throws or resolves a rejected promise. If the next
* handler fails to return a valid response, it will be turned into a
* valid error response before returning.
* @throws void
*/
public function __invoke(ServerRequestInterface $request, callable $next)
{
try {
$response = $next($request);
} catch (\Throwable $e) {
return $this->errorInvalidException($e);
}
if ($response instanceof ResponseInterface) {
return $response;
} elseif ($response instanceof PromiseInterface) {
return $response->then(function ($response) {
if ($response instanceof ResponseInterface) {
return $response;
} else {
return $this->errorInvalidResponse($response);
}
}, function ($e) {
// Promise rejected, always a `\Throwable` as of Promise v3
assert($e instanceof \Throwable || !\method_exists(PromiseInterface::class, 'catch')); // @phpstan-ignore-line
if ($e instanceof \Throwable) {
return $this->errorInvalidException($e);
} else { // @phpstan-ignore-line
// @phpstan-ignore-next-line
return $this->errorInvalidResponse(\React\Promise\reject($e)); // @codeCoverageIgnore
}
});
} elseif ($response instanceof \Generator) {
return $this->coroutine($response);
} else {
return $this->errorInvalidResponse($response);
}
}
private function coroutine(\Generator $generator): \Generator
{
do {
try {
if (!$generator->valid()) {
$response = $generator->getReturn();
if ($response instanceof ResponseInterface) {
return $response;
} else {
return $this->errorInvalidResponse($response);
}
}
} catch (\Throwable $e) {
return $this->errorInvalidException($e);
}
$promise = $generator->current();
if (!$promise instanceof PromiseInterface) {
$gref = new \ReflectionGenerator($generator);
return $this->errorInvalidCoroutine(
$promise,
$gref->getExecutingFile(),
$gref->getExecutingLine()
);
}
try {
$next = yield $promise;
} catch (\Throwable $e) {
try {
$generator->throw($e);
continue;
} catch (\Throwable $e) {
return $this->errorInvalidException($e);
}
}
try {
$generator->send($next);
} catch (\Throwable $e) {
return $this->errorInvalidException($e);
}
} while (true);
} // @codeCoverageIgnore
/** @internal */
public function requestNotFound(): ResponseInterface
{
return $this->htmlResponse(
Response::STATUS_NOT_FOUND,
'Page Not Found',
'Please check the URL in the address bar and try again.'
);
}
/**
* @internal
* @param list<string> $allowedMethods
*/
public function requestMethodNotAllowed(array $allowedMethods): ResponseInterface
{
$methods = \implode('/', \array_map(function (string $method) { return '<code>' . $method . '</code>'; }, $allowedMethods));
return $this->htmlResponse(
Response::STATUS_METHOD_NOT_ALLOWED,
'Method Not Allowed',
'Please check the URL in the address bar and try again with ' . $methods . ' request.'
)->withHeader('Allow', \implode(', ', $allowedMethods));
}
/** @internal */
public function requestProxyUnsupported(): ResponseInterface
{
return $this->htmlResponse(
Response::STATUS_BAD_REQUEST,
'Proxy Requests Not Allowed',
'Please check your settings and retry.'
);
}
private function errorInvalidException(\Throwable $e): ResponseInterface
{
$where = ' in ' . $this->where($e->getFile(), $e->getLine());
$message = '<code>' . $this->html->escape($e->getMessage()) . '</code>';
return $this->htmlResponse(
Response::STATUS_INTERNAL_SERVER_ERROR,
'Internal Server Error',
'The requested page failed to load, please try again later.',
'Expected request handler to return <code>' . ResponseInterface::class . '</code> but got uncaught <code>' . \get_class($e) . '</code> with message ' . $message . $where . '.'
);
}
/** @param mixed $value */
private function errorInvalidResponse($value): ResponseInterface
{
return $this->htmlResponse(
Response::STATUS_INTERNAL_SERVER_ERROR,
'Internal Server Error',
'The requested page failed to load, please try again later.',
'Expected request handler to return <code>' . ResponseInterface::class . '</code> but got <code>' . $this->describeType($value) . '</code>.'
);
}
/** @param mixed $value */
private function errorInvalidCoroutine($value, string $file, int $line): ResponseInterface
{
$where = ' near or before '. $this->where($file, $line) . '.';
return $this->htmlResponse(
Response::STATUS_INTERNAL_SERVER_ERROR,
'Internal Server Error',
'The requested page failed to load, please try again later.',
'Expected request handler to yield <code>' . PromiseInterface::class . '</code> but got <code>' . $this->describeType($value) . '</code>' . $where
);
}
private function where(string $file, int $line): string
{
return '<code title="See ' . $file . ' line ' . $line . '">' . \basename($file) . ':' . $line . '</code>';
}
private function htmlResponse(int $statusCode, string $title, string ...$info): ResponseInterface
{
return $this->html->statusResponse(
$statusCode,
'Error ' . $statusCode . ': ' .$title,
$title,
\implode('', \array_map(function (string $info) { return "<p>$info</p>\n"; }, $info))
);
}
/** @param mixed $value */
private function describeType($value): string
{
if ($value === null) {
return 'null';
} elseif (\is_scalar($value) && !\is_string($value)) {
return \var_export($value, true);
}
return \is_object($value) ? \get_class($value) : \gettype($value);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace FrameworkX;
use FrameworkX\Io\HtmlHandler;
use FrameworkX\Io\RedirectHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;
class FilesystemHandler
{
/** @var string */
private $root;
/**
* Mapping between file extension and MIME type to send in `Content-Type` response header
*
* @var array<string,string>
*/
private $mimetypes = array(
'atom' => 'application/atom+xml',
'bz2' => 'application/x-bzip2',
'css' => 'text/css',
'gif' => 'image/gif',
'gz' => 'application/gzip',
'htm' => 'text/html',
'html' => 'text/html',
'ico' => 'image/x-icon',
'jpeg' => 'image/jpeg',
'jpg' => 'image/jpeg',
'js' => 'text/javascript',
'json' => 'application/json',
'pdf' => 'application/pdf',
'png' => 'image/png',
'rss' => 'application/rss+xml',
'svg' => 'image/svg+xml',
'tar' => 'application/x-tar',
'xml' => 'application/xml',
'zip' => 'application/zip',
);
/**
* Assign default MIME type to send in `Content-Type` response header (same as nginx/Apache)
*
* @var string
* @see self::$mimetypes
*/
private $defaultMimetype = 'text/plain';
/** @var ErrorHandler */
private $errorHandler;
/** @var HtmlHandler */
private $html;
public function __construct(string $root)
{
$this->root = $root;
$this->errorHandler = new ErrorHandler();
$this->html = new HtmlHandler();
}
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
$local = $request->getAttribute('path', '');
assert(\is_string($local));
$path = \rtrim($this->root . '/' . $local, '/');
// local path should not contain "./", "../", "//" or null bytes or start with slash
$valid = !\preg_match('#(?:^|/)\.\.?(?:$|/)|^/|//|\x00#', $local);
\clearstatcache();
if ($valid && \is_dir($path)) {
if ($local !== '' && \substr($local, -1) !== '/') {
return (new RedirectHandler(\basename($path) . '/'))();
}
$response = '<strong>' . $this->html->escape($local === '' ? '/' : $local) . '</strong>' . "\n<ul>\n";
if ($local !== '') {
$response .= ' <li><a href="../">../</a></li>' . "\n";
}
$files = \scandir($path);
// @phpstan-ignore-next-line TODO handle error if directory can not be accessed
foreach ($files as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$dir = \is_dir($path . '/' . $file) ? '/' : '';
$response .= ' <li><a href="' . \rawurlencode($file) . $dir . '">' . $this->html->escape($file) . $dir . '</a></li>' . "\n";
}
$response .= '</ul>' . "\n";
return Response::html(
$response
);
} elseif ($valid && \is_file($path)) {
if ($local !== '' && \substr($local, -1) === '/') {
return (new RedirectHandler('../' . \basename($path)))();
}
// Assign MIME type based on file extension (same as nginx/Apache) or fall back to given default otherwise.
// Browsers are pretty good at figuring out the correct type if no charset attribute is given.
$ext = \strtolower(\substr($path, \strrpos($path, '.') + 1));
$headers = [
'Content-Type' => $this->mimetypes[$ext] ?? $this->defaultMimetype
];
$stat = @\stat($path);
if ($stat !== false) {
$headers['Last-Modified'] = \gmdate('D, d M Y H:i:s', $stat['mtime']) . ' GMT';
if ($request->getHeaderLine('If-Modified-Since') === $headers['Last-Modified']) {
return new Response(Response::STATUS_NOT_MODIFIED);
}
}
return new Response(
Response::STATUS_OK,
$headers,
\file_get_contents($path) // @phpstan-ignore-line TODO handle error if file can not be accessed
);
} else {
return $this->errorHandler->requestNotFound();
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace FrameworkX\Io;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
/**
* [Internal] Fibers middleware handler to ensure each request is processed in a separate `Fiber`
*
* The `Fiber` class has been added in PHP 8.1+, so this middleware is only used
* on PHP 8.1+. On supported PHP versions, this middleware is automatically
* added to the list of middleware handlers, so there's no need to reference
* this class in application code.
*
* @internal
* @link https://framework-x.org/docs/async/fibers/
*/
class FiberHandler
{
/**
* @return ResponseInterface|PromiseInterface<ResponseInterface>|\Generator
* Returns a `ResponseInterface` from the next request handler in the
* chain. If the next request handler returns immediately, this method
* will return immediately. If the next request handler suspends the
* fiber (see `await()`), this method will return a `PromiseInterface`
* that is fulfilled with a `ResponseInterface` when the fiber is
* terminated successfully. If the next request handler returns a
* promise, this method will return a promise that follows its
* resolution. If the next request handler returns a Generator-based
* coroutine, this method returns a `Generator`. This method never
* throws or resolves a rejected promise. If the handler fails, it will
* be turned into a valid error response before returning.
* @throws void
*/
public function __invoke(ServerRequestInterface $request, callable $next)
{
$deferred = null;
$fiber = new \Fiber(function () use ($request, $next, &$deferred) {
$response = $next($request);
assert($response instanceof ResponseInterface || $response instanceof PromiseInterface || $response instanceof \Generator);
// if the next request handler returns immediately, the fiber can terminate immediately without using a Deferred
// if the next request handler suspends the fiber, we only reach this point after resuming the fiber, so the code below will have assigned a Deferred
/** @var ?Deferred<ResponseInterface> $deferred */
if ($deferred !== null) {
assert($response instanceof ResponseInterface);
$deferred->resolve($response);
}
return $response;
});
/** @throws void because the next handler will always be an `ErrorHandler` */
$fiber->start();
if ($fiber->isTerminated()) {
/** @throws void because fiber is known to have terminated successfully */
/** @var ResponseInterface|PromiseInterface<ResponseInterface>|\Generator */
return $fiber->getReturn();
}
$deferred = new Deferred();
return $deferred->promise();
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace FrameworkX\Io;
use Psr\Http\Message\ResponseInterface;
use React\Http\Message\Response;
/**
* @internal
*/
class HtmlHandler
{
public function statusResponse(int $statusCode, string $title, string $subtitle, string $info): ResponseInterface
{
$nonce = \base64_encode(\random_bytes(16));
$html = <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>$title</title>
<style nonce="$nonce">
body { display: grid; justify-content: center; align-items: center; grid-auto-rows: minmax(min-content, calc(100vh - 4em)); margin: 2em; font-family: ui-sans-serif, Arial, "Noto Sans", sans-serif; }
@media (min-width: 700px) { main { display: grid; max-width: 700px; } }
h1 { margin: 0 .5em 0 0; border-right: calc(2 * max(0px, min(100vw - 700px + 1px, 1px))) solid #e3e4e7; padding-right: .5em; color: #aebdcc; font-size: 3em; }
strong { color: #111827; font-size: 3em; }
p { margin: .5em 0 0 0; grid-column: 2; color: #6b7280; }
code { padding: 0 .3em; background-color: #f5f6f9; } code span { padding: 0 .2em; border-radius: 3px; background-color: #0001; }
a { color: inherit; }
</style>
</head>
<body>
<main>
<h1>$statusCode</h1>
<strong>$subtitle</strong>
$info</main>
</body>
</html>
HTML;
return new Response(
$statusCode,
[
'Content-Type' => 'text/html; charset=utf-8',
'Content-Security-Policy' => "style-src 'nonce-$nonce'; img-src 'self'; default-src 'none'"
],
$html
);
}
public function escape(string $s): string
{
return (string) \preg_replace_callback(
'/[\x00-\x1F]+/',
function (array $match): string {
return '<span>' . \addcslashes($match[0], "\x00..\xff") . '</span>';
},
(string) \preg_replace(
'/(^| ) |(?: $)/',
'$1&nbsp;',
\htmlspecialchars($s, \ENT_NOQUOTES | \ENT_SUBSTITUTE | \ENT_DISALLOWED, 'utf-8')
)
);
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace FrameworkX\Io;
/**
* @internal
*/
class LogStreamHandler
{
/** @var ?resource */
private $stream;
/**
* @param string $path absolute log file path
* @throws \InvalidArgumentException if given `$path` is not an absolute file path
* @throws \RuntimeException if given `$path` can not be opened in append mode
*/
public function __construct(string $path)
{
if (\strpos($path, "\0") !== false || (\stripos($path, 'php://') !== 0 && !$this->isAbsolutePath($path))) {
throw new \InvalidArgumentException(
'Unable to open log file "' . \addslashes($path) . '": Invalid path given'
);
}
$errstr = '';
\set_error_handler(function (int $_, string $error) use (&$errstr): bool {
// Match errstr from PHP's warning message.
// fopen(/dev/not-a-valid-path): Failed to open stream: Permission denied
$errstr = \preg_replace('#.*: #', '', $error);
return true;
});
$stream = \fopen($path, 'ae');
// try to fstat($stream) to see if this points to /dev/null (skip on Windows)
// @codeCoverageIgnoreStart
$stat = false;
if ($stream !== false && \DIRECTORY_SEPARATOR !== '\\') {
if (\strtolower($path) === 'php://output') {
// php://output doesn't support stat, so assume php://output will go to php://stdout
$stdout = \defined('STDOUT') ? \STDOUT : \fopen('php://stdout', 'w');
if (\is_resource($stdout)) {
$stat = \fstat($stdout);
} else {
// STDOUT can not be opened => assume piping to /dev/null
$stream = null;
}
} else {
$stat = \fstat($stream);
}
// close stream if it points to /dev/null
if ($stat !== false && $stat === \stat('/dev/null')) {
$stream = null;
}
}
// @codeCoverageIgnoreEnd
\restore_error_handler();
if ($stream === false) {
throw new \RuntimeException(
'Unable to open log file "' . $path . '": ' . $errstr
);
}
$this->stream = $stream;
}
public function isDevNull(): bool
{
return $this->stream === null;
}
public function log(string $message): void
{
// nothing to do if we're writing to /dev/null
if ($this->stream === null) {
return; // @codeCoverageIgnore
}
$time = \microtime(true);
$prefix = \date('Y-m-d H:i:s', (int) $time) . \sprintf('.%03d ', (int) (($time - (int) $time) * 1e3));
$ret = \fwrite($this->stream, $prefix . $message . \PHP_EOL);
assert(\is_int($ret));
}
private function isAbsolutePath(string $path): bool
{
return \DIRECTORY_SEPARATOR !== '\\' ? \substr($path, 0, 1) === '/' : (bool) \preg_match('#^[A-Z]:[/\\\\]#i', $path);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace FrameworkX\Io;
use Psr\Http\Message\ServerRequestInterface;
/**
* @internal
*/
class MiddlewareHandler
{
/** @var list<callable> $handlers */
private $handlers;
/** @param list<callable> $handlers */
public function __construct(array $handlers)
{
assert(count($handlers) >= 2);
$this->handlers = $handlers;
}
/** @return mixed */
public function __invoke(ServerRequestInterface $request)
{
return $this->call($request, 0);
}
/** @return mixed */
private function call(ServerRequestInterface $request, int $position)
{
if (!isset($this->handlers[$position + 2])) {
return $this->handlers[$position]($request, $this->handlers[$position + 1]);
}
return $this->handlers[$position]($request, function (ServerRequestInterface $request) use ($position) {
return $this->call($request, $position + 1);
});
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace FrameworkX\Io;
use React\EventLoop\Loop;
use React\Http\HttpServer;
use React\Socket\SocketServer;
/**
* [Internal] Powerful reactive request handler built on top of ReactPHP.
*
* This is where the magic happens: The main `App` uses this class to run
* ReactPHP's efficient HTTP server to handle incoming HTTP requests when
* executed on the command line (CLI). ReactPHP's lightweight socket server can
* listen for a large number of concurrent connections and process multiple
* incoming connections simultaneously. The long-running server process will
* continue to run until it is interrupted by a signal.
*
* Note that this is an internal class only and nothing you should usually have
* to care about. See also the `App` and `SapiHandler` for more details.
*
* @internal
*/
class ReactiveHandler
{
/** @var LogStreamHandler */
private $logger;
/** @var string */
private $listenAddress;
public function __construct(?string $listenAddress)
{
/** @throws void */
$this->logger = new LogStreamHandler('php://output');
$this->listenAddress = $listenAddress ?? '127.0.0.1:8080';
}
public function run(callable $handler): void
{
$socket = new SocketServer($this->listenAddress);
// create HTTP server, automatically start new fiber for each request on PHP 8.1+
$http = new HttpServer(...(\PHP_VERSION_ID >= 80100 ? [new FiberHandler(), $handler] : [$handler]));
$http->listen($socket);
$logger = $this->logger;
$logger->log('Listening on ' . \str_replace('tcp:', 'http:', (string) $socket->getAddress()));
$http->on('error', static function (\Exception $e) use ($logger): void {
$logger->log('HTTP error: ' . $e->getMessage());
});
// @codeCoverageIgnoreStart
try {
Loop::addSignal(\defined('SIGINT') ? \SIGINT : 2, $f1 = static function () use ($socket, $logger): void {
if (\PHP_VERSION_ID >= 70200 && \stream_isatty(\STDIN)) {
echo "\r";
}
$logger->log('Received SIGINT, stopping loop');
$socket->close();
Loop::stop();
});
Loop::addSignal(\defined('SIGTERM') ? \SIGTERM : 15, $f2 = static function () use ($socket, $logger): void {
$logger->log('Received SIGTERM, stopping loop');
$socket->close();
Loop::stop();
});
} catch (\BadMethodCallException $e) {
$logger->log('Notice: No signal handler support, installing ext-ev or ext-pcntl recommended for production use.');
}
// @codeCoverageIgnoreEnd
do {
Loop::run();
if ($socket->getAddress() !== null) {
// Fiber compatibility mode for PHP < 8.1: Restart loop as long as socket is available
$logger->log('Warning: Loop restarted. Upgrade to react/async v4 recommended for production use.');
} else {
break;
}
} while (true);
// remove signal handlers when loop stops (if registered)
Loop::removeSignal(\defined('SIGINT') ? \SIGINT : 2, $f1 ?? 'printf');
Loop::removeSignal(\defined('SIGTERM') ? \SIGTERM : 15, $f2 ?? 'printf');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace FrameworkX\Io;
use Psr\Http\Message\ResponseInterface;
use React\Http\Message\Response;
/**
* @internal
*/
class RedirectHandler
{
/** @var string */
private $target;
/** @var int */
private $code;
/** @var string */
private $reason;
/** @var HtmlHandler */
private $html;
public function __construct(string $target, int $redirectStatusCode = Response::STATUS_FOUND)
{
if ($redirectStatusCode < 300 || $redirectStatusCode === Response::STATUS_NOT_MODIFIED || $redirectStatusCode >= 400) {
throw new \InvalidArgumentException('Invalid redirect status code given');
}
$this->target = $target;
$this->code = $redirectStatusCode;
$this->reason = \ucwords((new Response($redirectStatusCode))->getReasonPhrase()) ?: 'Redirect';
$this->html = new HtmlHandler();
}
public function __invoke(): ResponseInterface
{
$url = $this->html->escape($this->target);
return $this->html->statusResponse(
$this->code,
'Redirecting to ' . $url,
$this->reason,
"<p>Redirecting to <a href=\"$url\"><code>$url</code></a>...</p>\n"
)->withHeader('Location', $this->target);
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace FrameworkX\Io;
use FastRoute\DataGenerator\GroupCountBased as RouteGenerator;
use FastRoute\Dispatcher\GroupCountBased as RouteDispatcher;
use FastRoute\RouteCollector;
use FastRoute\RouteParser\Std as RouteParser;
use FrameworkX\AccessLogHandler;
use FrameworkX\Container;
use FrameworkX\ErrorHandler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\Promise\PromiseInterface;
/**
* @internal
*/
class RouteHandler
{
/** @var RouteCollector */
private $routeCollector;
/** @var ?RouteDispatcher */
private $routeDispatcher;
/** @var ErrorHandler */
private $errorHandler;
/** @var Container */
private $container;
public function __construct(Container $container = null)
{
$this->routeCollector = new RouteCollector(new RouteParser(), new RouteGenerator());
$this->errorHandler = new ErrorHandler();
$this->container = $container ?? new Container();
}
/**
* @param string[] $methods
* @param string $route
* @param callable|class-string $handler
* @param callable|class-string ...$handlers
*/
public function map(array $methods, string $route, $handler, ...$handlers): void
{
if ($handlers) {
\array_unshift($handlers, $handler);
\end($handlers);
} else {
$handlers = [$handler];
}
$last = key($handlers);
$container = $this->container;
foreach ($handlers as $i => $handler) {
if ($handler instanceof Container && $i !== $last) {
$container = $handler;
unset($handlers[$i]);
} elseif ($handler instanceof AccessLogHandler || $handler === AccessLogHandler::class) {
throw new \TypeError('AccessLogHandler may currently only be passed as a global middleware');
} elseif (!\is_callable($handler)) {
$handlers[$i] = $container->callable($handler);
}
}
/** @var non-empty-array<callable> $handlers */
$handler = \count($handlers) > 1 ? new MiddlewareHandler(array_values($handlers)) : \reset($handlers);
$this->routeDispatcher = null;
$this->routeCollector->addRoute($methods, $route, $handler);
}
/**
* @return ResponseInterface|PromiseInterface<ResponseInterface>|\Generator
*/
public function __invoke(ServerRequestInterface $request)
{
$target = $request->getRequestTarget();
if ($target[0] !== '/' && $target !== '*') {
return $this->errorHandler->requestProxyUnsupported();
} elseif ($target !== '*') {
$target = $request->getUri()->getPath();
}
if ($this->routeDispatcher === null) {
$this->routeDispatcher = new RouteDispatcher($this->routeCollector->getData());
}
$routeInfo = $this->routeDispatcher->dispatch($request->getMethod(), $target);
assert(\is_array($routeInfo) && isset($routeInfo[0]));
// happy path: matching route found, assign route attributes and invoke request handler
if ($routeInfo[0] === \FastRoute\Dispatcher::FOUND) {
$handler = $routeInfo[1];
$vars = $routeInfo[2];
foreach ($vars as $key => $value) {
$request = $request->withAttribute($key, rawurldecode($value));
}
return $handler($request);
}
// no matching route found: report error `404 Not Found`
if ($routeInfo[0] === \FastRoute\Dispatcher::NOT_FOUND) {
return $this->errorHandler->requestNotFound();
}
// unexpected request method for route: report error `405 Method Not Allowed`
assert($routeInfo[0] === \FastRoute\Dispatcher::METHOD_NOT_ALLOWED);
assert(\is_array($routeInfo[1]) && \count($routeInfo[1]) > 0);
return $this->errorHandler->requestMethodNotAllowed($routeInfo[1]);
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace FrameworkX\Io;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\Loop;
use React\Http\Message\Response;
use React\Http\Message\ServerRequest;
use React\Promise\PromiseInterface;
use React\Stream\ReadableStreamInterface;
/**
* [Internal] Request handler for traditional PHP SAPIs.
*
* This request handler will be used when executed behind traditional PHP SAPIs
* (PHP-FPM, FastCGI, Apache, etc.). It will handle a single request and run
* until a single response is sent. This is particularly useful because it
* allows you to run the exact same app in any environment.
*
* Note that this is an internal class only and nothing you should usually have
* to care about. See also the `App` and `ReactiveHandler` for more details.
*
* @internal
*/
class SapiHandler
{
public function run(callable $handler): void
{
$request = $this->requestFromGlobals();
$response = $handler($request);
if ($response instanceof ResponseInterface) {
$this->sendResponse($response);
} elseif ($response instanceof PromiseInterface) {
/** @var PromiseInterface<ResponseInterface> $response */
$response->then(function (ResponseInterface $response): void {
$this->sendResponse($response);
});
}
Loop::run();
}
public function requestFromGlobals(): ServerRequestInterface
{
$host = null;
$headers = array();
// @codeCoverageIgnoreStart
if (\function_exists('getallheaders')) {
$headers = \getallheaders();
$host = \array_change_key_case($headers, \CASE_LOWER)['host'] ?? null;
} else {
foreach ($_SERVER as $key => $value) {
if (\strpos($key, 'HTTP_') === 0) {
$key = str_replace(' ', '-', \ucwords(\strtolower(\str_replace('_', ' ', \substr($key, 5)))));
$headers[$key] = $value;
if ($host === null && $key === 'Host') {
$host = $value;
}
}
}
}
// @codeCoverageIgnoreEnd
$target = ($_SERVER['REQUEST_URI'] ?? '/');
$url = $target;
if (($target[0] ?? '/') === '/' || $target === '*') {
$url = (($_SERVER['HTTPS'] ?? null) === 'on' ? 'https://' : 'http://') . ($host ?? 'localhost') . ($target === '*' ? '' : $target);
}
$body = file_get_contents('php://input');
assert(\is_string($body));
$request = new ServerRequest(
$_SERVER['REQUEST_METHOD'] ?? 'GET',
$url,
$headers,
$body,
substr($_SERVER['SERVER_PROTOCOL'] ?? 'http/1.1', 5),
$_SERVER
);
if ($host === null) {
$request = $request->withoutHeader('Host');
}
if (isset($target[0]) && $target[0] !== '/') {
$request = $request->withRequestTarget($target);
}
$request = $request->withParsedBody($_POST);
// Content-Length / Content-Type are special <3
if ($request->getHeaderLine('Content-Length') === '') {
$request = $request->withoutHeader('Content-Length');
}
if ($request->getHeaderLine('Content-Type') === '' && !isset($_SERVER['HTTP_CONTENT_TYPE'])) {
$request = $request->withoutHeader('Content-Type');
}
return $request;
}
/**
* @param ResponseInterface $response
*/
public function sendResponse(ResponseInterface $response): void
{
$status = $response->getStatusCode();
$body = $response->getBody();
if ($status === Response::STATUS_NO_CONTENT) {
// `204 No Content` MUST NOT include "Content-Length" response header
$response = $response->withoutHeader('Content-Length');
} elseif (!$response->hasHeader('Content-Length') && $body->getSize() !== null && ($status !== Response::STATUS_NOT_MODIFIED || $body->getSize() !== 0)) {
// automatically assign "Content-Length" response header if known and not already present
$response = $response->withHeader('Content-Length', (string) $body->getSize());
}
// remove default "Content-Type" header set by PHP (default_mimetype)
if (!$response->hasHeader('Content-Type')) {
header('Content-Type:');
header_remove('Content-Type');
}
// send all headers without applying default "; charset=utf-8" set by PHP (default_charset)
$old = ini_get('default_charset');
assert(\is_string($old));
ini_set('default_charset', '');
foreach ($response->getHeaders() as $name => $values) {
foreach ($values as $value) {
header($name . ': ' . $value, false);
}
}
ini_set('default_charset', $old);
header($_SERVER['SERVER_PROTOCOL'] . ' ' . $status . ' ' . $response->getReasonPhrase());
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'HEAD' || $status === Response::STATUS_NO_CONTENT || $status === Response::STATUS_NOT_MODIFIED) {
$body->close();
return;
}
if ($body instanceof ReadableStreamInterface) {
// try to disable nginx buffering (http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering)
if (isset($_SERVER['SERVER_SOFTWARE']) && strpos($_SERVER['SERVER_SOFTWARE'], 'nginx') === 0) {
header('X-Accel-Buffering: no');
}
// clear output buffer to show streaming output (default in cli-server)
if (\PHP_SAPI === 'cli-server') {
\ob_end_flush(); // @codeCoverageIgnore
}
// flush data whenever stream reports one data chunk
$body->on('data', function ($chunk) {
echo $chunk;
flush();
});
} else {
echo $body;
}
}
}