diff --git a/composer.json b/composer.json index b1d530e233186..23b9d4dc94fdb 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ "symfony/polyfill-mbstring": "~1.0", "symfony/polyfill-php73": "^1.11", "symfony/polyfill-php80": "^1.15", - "symfony/polyfill-uuid": "^1.15" + "symfony/polyfill-uuid": "^1.15", + "symfony/runtime": "self.version" }, "replace": { "symfony/asset": "self.version", @@ -168,6 +169,10 @@ { "type": "path", "url": "src/Symfony/Contracts" + }, + { + "type": "path", + "url": "src/Symfony/Component/Runtime" } ], "minimum-stability": "dev", diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php index 84d14a86ec27c..96f8fb19a8e27 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php @@ -50,6 +50,7 @@ use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\HttpKernel\UriSigner; +use Symfony\Component\Runtime\SymfonyRuntime; use Symfony\Component\String\LazyString; use Symfony\Component\String\Slugger\AsciiSlugger; use Symfony\Component\String\Slugger\SluggerInterface; @@ -114,6 +115,7 @@ service('argument_resolver'), ]) ->tag('container.hot_path') + ->tag('container.preload', ['class' => SymfonyRuntime::class]) ->alias(HttpKernelInterface::class, 'http_kernel') ->set('request_stack', RequestStack::class) diff --git a/src/Symfony/Component/Runtime/.gitattributes b/src/Symfony/Component/Runtime/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Runtime/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Runtime/.gitignore b/src/Symfony/Component/Runtime/.gitignore new file mode 100644 index 0000000000000..207f97e0236ed --- /dev/null +++ b/src/Symfony/Component/Runtime/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/composer.lock +/phpunit.xml diff --git a/src/Symfony/Component/Runtime/BaseRuntime.php b/src/Symfony/Component/Runtime/BaseRuntime.php new file mode 100644 index 0000000000000..4434dc52bc67a --- /dev/null +++ b/src/Symfony/Component/Runtime/BaseRuntime.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Runtime; + +use Symfony\Component\Runtime\Internal\BasicErrorHandler; +use Symfony\Component\Runtime\ResolvedApp\ClosureResolved; +use Symfony\Component\Runtime\ResolvedApp\ScalarResolved; +use Symfony\Component\Runtime\StartedApp\ClosureStarted; + +// Help opcache.preload discover always-needed symbols +class_exists(ClosureResolved::class); +class_exists(BasicErrorHandler::class); + +/** + * A runtime to do bare-metal PHP without using superglobals. + * + * One option named "debug" is supported; it toggles displaying errors. + * + * The app-closure returned by the entry script must return either: + * - "string" to echo the response content, or + * - "int" to set the exit status code. + * + * The app-closure can declare arguments among either: + * - "array $context" to get a local array similar to $_SERVER; + * - "array $argv" to get the command line arguments when running on the CLI; + * - "array $request" to get a local array with keys "query", "data", "files" and + * "session", which map to $_GET, $_POST, $FILES and &$_SESSION respectively. + * + * The runtime sets up a strict error handler that throws + * exceptions when a PHP warning/notice is raised. + * + * @author Nicolas Grekas
+ */ +class BaseRuntime implements RuntimeInterface +{ + private $debug; + + public function __construct(array $options = []) + { + $this->debug = $options['debug'] ?? true; + $errorHandler = new BasicErrorHandler($this->debug); + set_error_handler($errorHandler); + set_exception_handler([$errorHandler, 'handleException']); + } + + public function resolve(\Closure $app): ResolvedAppInterface + { + $arguments = []; + $function = new \ReflectionFunction($app); + + try { + foreach ($function->getParameters() as $parameter) { + $arguments[] = $this->getArgument($parameter, $parameter->getType()); + } + } catch (\InvalidArgumentException $e) { + if (!$parameter->isOptional()) { + throw $e; + } + } + + $returnType = $function->getReturnType(); + + switch ($returnType instanceof \ReflectionNamedType ? $returnType->getName() : '') { + case 'string': + return new ScalarResolved(static function () use ($app, $arguments): int { + echo $app(...$arguments); + + return 0; + }); + + case 'int': + case 'void': + return new ScalarResolved(static function () use ($app, $arguments): int { + return $app(...$arguments) ?? 0; + }); + } + + return new ClosureResolved($app, $arguments); + } + + public function start(object $app): StartedAppInterface + { + if (!$app instanceof \Closure) { + throw new \LogicException(sprintf('"%s" doesn\'t know how to handle apps of type "%s".', get_debug_type($this), get_debug_type($app))); + } + + if ($this->debug && (new \ReflectionFunction($app))->getNumberOfRequiredParameters()) { + throw new \ArgumentCountError('Zero argument should be required by the closure returned by the app, but at least one is.'); + } + + return new ClosureStarted($app); + } + + protected function getArgument(\ReflectionParameter $parameter, ?\ReflectionType $type) + { + $type = $type instanceof \ReflectionNamedType ? $type->getName() : ''; + + if (RuntimeInterface::class === $type) { + return $this; + } + + if ('array' !== $type) { + throw new \InvalidArgumentException(sprintf('Cannot resolve argument "%s $%s".', $type, $parameter->name)); + } + + switch ($parameter->name) { + case 'context': + $context = $_SERVER; + + if ($_ENV && !isset($_SERVER['PATH']) && !isset($_SERVER['Path'])) { + $context += $_ENV; + } + + return $context; + + case 'argv': + return $_SERVER['argv'] ?? []; + + case 'request': + return [ + 'query' => $_GET, + 'data' => $_POST, + 'files' => $_FILES, + 'session' => &$_SESSION, + ]; + } + + throw new \InvalidArgumentException(sprintf('Cannot resolve array argument "$%s", did you mean "$context" or "$request"?', $parameter->name)); + } +} diff --git a/src/Symfony/Component/Runtime/CHANGELOG.md b/src/Symfony/Component/Runtime/CHANGELOG.md new file mode 100644 index 0000000000000..1e70f9a64318a --- /dev/null +++ b/src/Symfony/Component/Runtime/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.2.0 +----- + + * added the component diff --git a/src/Symfony/Component/Runtime/Internal/BasicErrorHandler.php b/src/Symfony/Component/Runtime/Internal/BasicErrorHandler.php new file mode 100644 index 0000000000000..4a439928d5bad --- /dev/null +++ b/src/Symfony/Component/Runtime/Internal/BasicErrorHandler.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Runtime\Internal; + +/** + * @author Nicolas Grekas
+ * + * @internal + */ +class BasicErrorHandler +{ + public function __construct(bool $debug) + { + error_reporting(-1); + + if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) { + ini_set('display_errors', $debug); + } elseif (!filter_var(ini_get('log_errors'), FILTER_VALIDATE_BOOLEAN) || ini_get('error_log')) { + // CLI - display errors only if they're not already logged to STDERR + ini_set('display_errors', 1); + } + + if (0 <= ini_get('zend.assertions')) { + ini_set('zend.assertions', 1); + ini_set('assert.active', $debug); + ini_set('assert.bail', 0); + ini_set('assert.warning', 0); + ini_set('assert.exception', 1); + } + } + + public function __invoke(int $type, string $msg, string $file, int $line): bool + { + if ((E_DEPRECATED | E_USER_DEPRECATED) & $type) { + return true; + } + + if ((error_reporting() | E_ERROR | E_RECOVERABLE_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR) & $type) { + throw new \ErrorException($msg, 0, $type, $file, $line); + } + + return false; + } + + public function handleException(\Throwable $e): void + { + echo "
\n$e\n\n"; + } +} diff --git a/src/Symfony/Component/Runtime/Internal/ComposerPlugin.php b/src/Symfony/Component/Runtime/Internal/ComposerPlugin.php new file mode 100644 index 0000000000000..3d470baf4a0b7 --- /dev/null +++ b/src/Symfony/Component/Runtime/Internal/ComposerPlugin.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Runtime\Internal; + +use Composer\Composer; +use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\Factory; +use Composer\IO\IOInterface; +use Composer\Plugin\PluginInterface; +use Composer\Script\ScriptEvents; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @author Nicolas Grekas
+ *
+ * @internal
+ */
+class ComposerPlugin implements PluginInterface, EventSubscriberInterface
+{
+ /**
+ * @var Composer
+ */
+ private $composer;
+
+ /**
+ * @var IOInterface
+ */
+ private $io;
+
+ private static $activated = false;
+
+ public function activate(Composer $composer, IOInterface $io)
+ {
+ self::$activated = true;
+ $this->composer = $composer;
+ $this->io = $io;
+ }
+
+ public function deactivate(Composer $composer, IOInterface $io)
+ {
+ self::$activated = false;
+ }
+
+ public function uninstall(Composer $composer, IOInterface $io)
+ {
+ @unlink($composer->getConfig()->get('vendor-dir').'/autoload_runtime.php');
+ }
+
+ public function updateAutoloadFile()
+ {
+ $vendorDir = $this->composer->getConfig()->get('vendor-dir');
+ $autoloadFile = $vendorDir.'/autoload.php';
+
+ if (!file_exists($autoloadFile)) {
+ return;
+ }
+
+ $projectDir = (new Filesystem())->makePathRelative(\dirname(realpath(Factory::getComposerFile())), $vendorDir);
+ $nestingLevel = 0;
+
+ while (0 === strpos($projectDir, '../')) {
+ ++$nestingLevel;
+ $projectDir = substr($projectDir, 3);
+ }
+
+ if (!$nestingLevel) {
+ $projectDir = '__DIR__.'.var_export('/'.$projectDir, true);
+ } else {
+ $projectDir = "dirname(__DIR__, $nestingLevel)".('' !== $projectDir ? var_export('/'.$projectDir, true) : '');
+ }
+
+ $code = <<<'EOPHP'
+ %project_dir%]);
+$app = $runtime->resolve($app)();
+exit($runtime->start($app)());
+
+EOPHP;
+
+ file_put_contents(substr_replace($autoloadFile, '_runtime', -4, 0), strtr($code, [
+ '%project_dir%' => $projectDir,
+ ]));
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ if (!self::$activated) {
+ return [];
+ }
+
+ return [
+ ScriptEvents::POST_AUTOLOAD_DUMP => 'updateAutoloadFile',
+ ];
+ }
+}
diff --git a/src/Symfony/Component/Runtime/Internal/MissingDotenv.php b/src/Symfony/Component/Runtime/Internal/MissingDotenv.php
new file mode 100644
index 0000000000000..896865653eee8
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Internal/MissingDotenv.php
@@ -0,0 +1,19 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime\Internal;
+
+/**
+ * @internal class that should be loaded only when symfony/dotenv is not installed
+ */
+class MissingDotenv
+{
+}
diff --git a/src/Symfony/Component/Runtime/Internal/MissingErrorHandler.php b/src/Symfony/Component/Runtime/Internal/MissingErrorHandler.php
new file mode 100644
index 0000000000000..b2c196504c4da
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Internal/MissingErrorHandler.php
@@ -0,0 +1,19 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime\Internal;
+
+/**
+ * @internal class that should be loaded only when symfony/error-handler is not installed
+ */
+class MissingErrorHandler
+{
+}
diff --git a/src/Symfony/Component/Runtime/LICENSE b/src/Symfony/Component/Runtime/LICENSE
new file mode 100644
index 0000000000000..5593b1d84f74a
--- /dev/null
+++ b/src/Symfony/Component/Runtime/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2020 Fabien Potencier
+
+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.
diff --git a/src/Symfony/Component/Runtime/README.md b/src/Symfony/Component/Runtime/README.md
new file mode 100644
index 0000000000000..eaca0c16ce518
--- /dev/null
+++ b/src/Symfony/Component/Runtime/README.md
@@ -0,0 +1,104 @@
+Runtime Component
+=================
+
+Symfony Runtime enables decoupling apps from global state.
+
+Getting Started
+---------------
+
+```
+$ composer require symfony/runtime
+```
+
+RuntimeInterface
+----------------
+
+The core of this component is the `RuntimeInterface` which describes a high-order
+runtime logic.
+
+It is designed to be totally generic and able to run any application outside of
+the global state in 6 steps:
+
+ 1. your front-controller returns a closure that wraps your app;
+ 2. the arguments of this closure are resolved by `RuntimeInterface::resolve()`
+ which returns a `ResolvedAppInterface`. This is an invokable with zero
+ arguments that returns whatever object of yours represents your app
+ (e.g a Symfony kernel or response, a console application or command);
+ 3. this invokable is called and returns this object that represents your app;
+ 4. your app object is passed to `RuntimeInterface::start()`, which returns a
+ `StartedAppInterface`: an invokable that knows how to "run" your app;
+ 5. that invokable is called and returns the exit status code as int;
+ 6. the PHP engine is exited with this status code.
+
+This process is extremely flexible as it allows implementations of
+`RuntimeInterface` to hook into any critical steps.
+
+Autoloading
+-----------
+
+This package registers itself as a Composer plugin to generate a
+`vendor/autoload_runtime.php` file. You need to require it instead of the usual
+`vendor/autoload.php` in front-controllers that leverage this component and
+return a closure.
+
+Before requiring the `vendor/autoload_runtime.php` file, you can set the
+`$_SERVER['APP_RUNTIME']` variable to a class that implements `RuntimeInterface`
+and that should be used to run the app.
+
+A `SymfonyRuntime` is used by default. It knows the conventions to run
+Symfony and native PHP apps.
+
+Examples
+--------
+
+This `public/index.php` is a "Hello World" that handles a "name" query parameter:
+```php
+addArgument('name', null, 'Who should I greet?', 'World');
+
+ return function (InputInterface $input, OutputInterface $output) {
+ $name = $input->getArgument('name');
+ $output->writeln(sprintf('Hello
+ */
+class ClosureResolved implements ResolvedAppInterface
+{
+ private $closure;
+ private $arguments;
+
+ public function __construct(\Closure $closure, array $arguments)
+ {
+ $this->closure = $closure;
+ $this->arguments = $arguments;
+ }
+
+ public function __invoke(): object
+ {
+ if (\is_object($app = ($this->closure)(...$this->arguments))) {
+ return $app;
+ }
+
+ if (null === $app || \is_string($app) || \is_int($app)) {
+ throw new \TypeError(sprintf('The app returned a value of type "%s" but no explicit return-type was found, did you forget to declare one?', get_debug_type($app)));
+ }
+
+ throw new \TypeError(sprintf('The app returned a value of type "%s" while an object was expected.', get_debug_type($app)));
+ }
+}
diff --git a/src/Symfony/Component/Runtime/ResolvedApp/ScalarResolved.php b/src/Symfony/Component/Runtime/ResolvedApp/ScalarResolved.php
new file mode 100644
index 0000000000000..6a8353d315ef3
--- /dev/null
+++ b/src/Symfony/Component/Runtime/ResolvedApp/ScalarResolved.php
@@ -0,0 +1,32 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime\ResolvedApp;
+
+use Symfony\Component\Runtime\ResolvedAppInterface;
+
+/**
+ * @author Nicolas Grekas
+ */
+class ScalarResolved implements ResolvedAppInterface
+{
+ private $closure;
+
+ public function __construct(\Closure $closure)
+ {
+ $this->closure = $closure;
+ }
+
+ public function __invoke(): object
+ {
+ return $this->closure;
+ }
+}
diff --git a/src/Symfony/Component/Runtime/ResolvedApp/Symfony/CommandResolved.php b/src/Symfony/Component/Runtime/ResolvedApp/Symfony/CommandResolved.php
new file mode 100644
index 0000000000000..1d6ada5e178ef
--- /dev/null
+++ b/src/Symfony/Component/Runtime/ResolvedApp/Symfony/CommandResolved.php
@@ -0,0 +1,53 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime\ResolvedApp\Symfony;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Runtime\ResolvedAppInterface;
+
+/**
+ * @author Nicolas Grekas
+ */
+class CommandResolved implements ResolvedAppInterface
+{
+ private $command;
+ private $resolvedApp;
+
+ public function __construct(Command $command, ResolvedAppInterface $resolvedApp)
+ {
+ $this->command = $command;
+ $this->resolvedApp = $resolvedApp;
+ }
+
+ public function __invoke(): object
+ {
+ if (!($app = ($this->resolvedApp)()) instanceof \Closure) {
+ return $app;
+ }
+
+ $parameters = (new \ReflectionFunction($app))->getParameters();
+ $types = [];
+
+ foreach ($parameters as $parameter) {
+ $type = $parameter->getType();
+ $types[] = $type instanceof \ReflectionNamedType ? $type->getName() : null;
+ }
+
+ if ([InputInterface::class, OutputInterface::class] === $types) {
+ return $this->command->setCode($app);
+ }
+
+ return $app;
+ }
+}
diff --git a/src/Symfony/Component/Runtime/ResolvedAppInterface.php b/src/Symfony/Component/Runtime/ResolvedAppInterface.php
new file mode 100644
index 0000000000000..801f70201b4a7
--- /dev/null
+++ b/src/Symfony/Component/Runtime/ResolvedAppInterface.php
@@ -0,0 +1,20 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime;
+
+/**
+ * @author Nicolas Grekas
+ */
+interface ResolvedAppInterface
+{
+ public function __invoke(): object;
+}
diff --git a/src/Symfony/Component/Runtime/RuntimeInterface.php b/src/Symfony/Component/Runtime/RuntimeInterface.php
new file mode 100644
index 0000000000000..5ee1dd03097b5
--- /dev/null
+++ b/src/Symfony/Component/Runtime/RuntimeInterface.php
@@ -0,0 +1,34 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime;
+
+/**
+ * Enables decoupling apps from global state.
+ *
+ * @author Nicolas Grekas
+ */
+interface RuntimeInterface
+{
+ /**
+ * Returns a resolver that should return the object representing your app.
+ *
+ * This object representing your app should then be passed to the start() method.
+ */
+ public function resolve(\Closure $app): ResolvedAppInterface;
+
+ /**
+ * Returns a starter that runs the app and returns its exit status.
+ *
+ * The passed object should be created by calling the resolver returned by the resolve() method.
+ */
+ public function start(object $app): StartedAppInterface;
+}
diff --git a/src/Symfony/Component/Runtime/StartedApp/ClosureStarted.php b/src/Symfony/Component/Runtime/StartedApp/ClosureStarted.php
new file mode 100644
index 0000000000000..fbd4d25c75903
--- /dev/null
+++ b/src/Symfony/Component/Runtime/StartedApp/ClosureStarted.php
@@ -0,0 +1,32 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime\StartedApp;
+
+use Symfony\Component\Runtime\StartedAppInterface;
+
+/**
+ * @author Nicolas Grekas
+ */
+class ClosureStarted implements StartedAppInterface
+{
+ private $app;
+
+ public function __construct(\Closure $app)
+ {
+ $this->app = $app;
+ }
+
+ public function __invoke(): int
+ {
+ return ($this->app)() ?? 0;
+ }
+}
diff --git a/src/Symfony/Component/Runtime/StartedApp/Symfony/ApplicationStarted.php b/src/Symfony/Component/Runtime/StartedApp/Symfony/ApplicationStarted.php
new file mode 100644
index 0000000000000..b675ab2f47905
--- /dev/null
+++ b/src/Symfony/Component/Runtime/StartedApp/Symfony/ApplicationStarted.php
@@ -0,0 +1,56 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime\StartedApp\Symfony;
+
+use Symfony\Component\Console\Application;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Runtime\StartedAppInterface;
+
+/**
+ * @author Nicolas Grekas
+ */
+class ApplicationStarted implements StartedAppInterface
+{
+ private $application;
+ private $defaultEnv;
+ private $input;
+ private $output;
+
+ public function __construct(Application $application, ?string $defaultEnv, InputInterface $input, OutputInterface $output = null)
+ {
+ $this->application = $application;
+ $this->defaultEnv = $defaultEnv;
+ $this->input = $input;
+ $this->output = $output;
+ }
+
+ public function __invoke(): int
+ {
+ if (null === $this->defaultEnv) {
+ return $this->application->run($this->input, $this->output);
+ }
+
+ $definition = $this->application->getDefinition();
+
+ if (!$definition->hasOption('env') && !$definition->hasOption('e') && !$definition->hasShortcut('e')) {
+ $definition->addOption(new InputOption('--env', '-e', InputOption::VALUE_REQUIRED, 'The Environment name.', $this->defaultEnv));
+ }
+
+ if (!$definition->hasOption('no-debug')) {
+ $definition->addOption(new InputOption('--no-debug', null, InputOption::VALUE_NONE, 'Switches off debug mode.'));
+ }
+
+ return $this->application->run($this->input);
+ }
+}
diff --git a/src/Symfony/Component/Runtime/StartedApp/Symfony/HttpKernelStarted.php b/src/Symfony/Component/Runtime/StartedApp/Symfony/HttpKernelStarted.php
new file mode 100644
index 0000000000000..631cdd1c45e29
--- /dev/null
+++ b/src/Symfony/Component/Runtime/StartedApp/Symfony/HttpKernelStarted.php
@@ -0,0 +1,47 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime\StartedApp\Symfony;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+use Symfony\Component\HttpKernel\TerminableInterface;
+use Symfony\Component\Runtime\RuntimeInterface;
+use Symfony\Component\Runtime\StartedAppInterface;
+
+/**
+ * @author Nicolas Grekas
+ */
+class HttpKernelStarted implements StartedAppInterface
+{
+ private $kernel;
+ private $request;
+ private $runtime;
+
+ public function __construct(HttpKernelInterface $kernel, Request $request, RuntimeInterface $runtime)
+ {
+ $this->kernel = $kernel;
+ $this->request = $request;
+ $this->runtime = $runtime;
+ }
+
+ public function __invoke(): int
+ {
+ $response = $this->kernel->handle($this->request);
+ $status = $this->runtime->start($response)();
+
+ if ($this->kernel instanceof TerminableInterface) {
+ $this->kernel->terminate($this->request, $response);
+ }
+
+ return $status;
+ }
+}
diff --git a/src/Symfony/Component/Runtime/StartedApp/Symfony/ResponseStarted.php b/src/Symfony/Component/Runtime/StartedApp/Symfony/ResponseStarted.php
new file mode 100644
index 0000000000000..c2072319419df
--- /dev/null
+++ b/src/Symfony/Component/Runtime/StartedApp/Symfony/ResponseStarted.php
@@ -0,0 +1,35 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime\StartedApp\Symfony;
+
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Runtime\StartedAppInterface;
+
+/**
+ * @author Nicolas Grekas
+ */
+class ResponseStarted implements StartedAppInterface
+{
+ private $response;
+
+ public function __construct(Response $response)
+ {
+ $this->response = $response;
+ }
+
+ public function __invoke(): int
+ {
+ $this->response->send();
+
+ return 0;
+ }
+}
diff --git a/src/Symfony/Component/Runtime/StartedAppInterface.php b/src/Symfony/Component/Runtime/StartedAppInterface.php
new file mode 100644
index 0000000000000..105936850b531
--- /dev/null
+++ b/src/Symfony/Component/Runtime/StartedAppInterface.php
@@ -0,0 +1,20 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime;
+
+/**
+ * @author Nicolas Grekas
+ */
+interface StartedAppInterface
+{
+ public function __invoke(): int;
+}
diff --git a/src/Symfony/Component/Runtime/SymfonyRuntime.php b/src/Symfony/Component/Runtime/SymfonyRuntime.php
new file mode 100644
index 0000000000000..d99aba411ca74
--- /dev/null
+++ b/src/Symfony/Component/Runtime/SymfonyRuntime.php
@@ -0,0 +1,200 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Runtime;
+
+use Symfony\Component\Console\Application;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\ArgvInput;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\ConsoleOutput;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Dotenv\Dotenv;
+use Symfony\Component\ErrorHandler\Debug;
+use Symfony\Component\ErrorHandler\ErrorHandler;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+use Symfony\Component\Runtime\Internal\MissingDotenv;
+use Symfony\Component\Runtime\Internal\MissingErrorHandler;
+use Symfony\Component\Runtime\ResolvedApp\Symfony\CommandResolved;
+use Symfony\Component\Runtime\StartedApp\Symfony\ApplicationStarted;
+use Symfony\Component\Runtime\StartedApp\Symfony\HttpKernelStarted;
+use Symfony\Component\Runtime\StartedApp\Symfony\ResponseStarted;
+
+// Help opcache.preload discover always-needed symbols
+class_exists(ResponseStarted::class);
+class_exists(HttpKernelStarted::class);
+class_exists(MissingDotenv::class, false) || class_exists(Dotenv::class) || class_exists(MissingDotenv::class);
+class_exists(MissingErrorHandler::class, false) || class_exists(ErrorHandler::class) || class_exists(MissingErrorHandler::class);
+
+/**
+ * Knows the basic conventions to run Symfony apps.
+ *
+ * Accepts two options:
+ * - "debug" to toggle debugging features;
+ * - "env" to define the name of the environment the app runs in.
+ *
+ * When these options are not defined, they will fallback:
+ * - to reading the "APP_DEBUG" and "APP_ENV" environment variables;
+ * - to parsing the "--env|-e" and "--no-debug" command line arguments
+ * if the "symfony/console" component is installed.
+ *
+ * When the "symfony/dotenv" component is installed, .env files are loaded.
+ * When "symfony/error-handler" is installed, it is used to improve error handling.
+ *
+ * On top of the base arguments provided by the parent runtime,
+ * this runtime can feed the app-closure with arguments of type:
+ * - Request from "symfony/http-foundation" if the component is installed;
+ * - Application, Command, InputInterface and/or OutputInterface
+ * from "symfony/console" if the component is installed.
+ *
+ * This runtime can handle app-closures that return instances of either:
+ * - HttpKernelInterface,
+ * - Response,
+ * - Application,
+ * - Command,
+ * - or Closure(): int
+ *
+ * @author Nicolas Grekas
+ */
+class SymfonyRuntime extends BaseRuntime
+{
+ private $request;
+ private $input;
+ private $output;
+ private $application;
+ private $command;
+ private $env;
+
+ public function __construct(array $options = [])
+ {
+ $this->env = $options['env'] ?? null;
+ $_SERVER['APP_ENV'] = $options['env'] ?? $_SERVER['APP_ENV'] ?? null;
+
+ parent::__construct($options);
+
+ if (isset($_SERVER['argv']) && null === $this->env && class_exists(ArgvInput::class)) {
+ $this->getInput();
+ }
+
+ if (class_exists(MissingDotenv::class, false) || !isset($options['project_dir'])) {
+ $_SERVER['APP_DEBUG'] = filter_var($options['debug'] ?? $_SERVER['APP_DEBUG'] ?? true, FILTER_VALIDATE_BOOLEAN) ? '1' : '0';
+ } else {
+ (new Dotenv())->bootEnv($options['project_dir'].'/.env');
+ }
+
+ if (class_exists(MissingErrorHandler::class, false)) {
+ // no-op
+ } elseif ($_SERVER['APP_DEBUG']) {
+ umask(0000);
+ Debug::enable();
+ } else {
+ ErrorHandler::register();
+ }
+ }
+
+ public function resolve(\Closure $app): ResolvedAppInterface
+ {
+ $app = parent::resolve($app);
+
+ return $this->command ? new CommandResolved($this->command, $app) : $app;
+ }
+
+ public function start(object $app): StartedAppInterface
+ {
+ if ($app instanceof HttpKernelInterface) {
+ $request = $this->request ?? $this->request = Request::createFromGlobals();
+
+ return new HttpKernelStarted($app, $request, $this);
+ }
+
+ if ($app instanceof Response) {
+ return new ResponseStarted($app);
+ }
+
+ if ($app instanceof Command) {
+ $application = $this->application ?? $this->application = new Application();
+ $application->setName($app->getName() ?: $application->getName());
+
+ if (!$app->getName() || !$application->has($app->getName())) {
+ $app->setName($_SERVER['argv'][0]);
+ $application->add($app);
+ }
+
+ $application->setDefaultCommand($app->getName(), true);
+
+ return $this->start($application);
+ }
+
+ if ($app instanceof Application) {
+ if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) {
+ echo 'Warning: The console should be invoked via the CLI version of PHP, not the '.\PHP_SAPI.' SAPI'.PHP_EOL;
+ }
+
+ set_time_limit(0);
+ $defaultEnv = null === $this->env ? ($_SERVER['APP_ENV'] ?? 'dev') : null;
+
+ return new ApplicationStarted($app, $defaultEnv, $this->getInput(), $this->output);
+ }
+
+ return parent::start($app);
+ }
+
+ protected function getArgument(\ReflectionParameter $parameter, ?\ReflectionType $type)
+ {
+ if (!$type instanceof \ReflectionNamedType) {
+ return parent::getArgument($parameter, $type);
+ }
+
+ switch ($type->getName()) {
+ case Request::class:
+ return $this->request ?? $this->request = Request::createFromGlobals();
+
+ case InputInterface::class:
+ return $this->getInput();
+
+ case OutputInterface::class:
+ return $this->output ?? $this->output = new ConsoleOutput();
+
+ case Application::class:
+ return $this->application ?? $this->application = new Application();
+
+ case Command::class:
+ return $this->command ?? $this->command = new Command();
+ }
+
+ return parent::getArgument($parameter, $type);
+ }
+
+ private function getInput(): ArgvInput
+ {
+ if (null !== $this->input) {
+ return $this->input;
+ }
+
+ $input = new ArgvInput();
+
+ if (null !== $this->env) {
+ return $this->input = $input;
+ }
+
+ if (null !== $env = $input->getParameterOption(['--env', '-e'], null, true)) {
+ putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env);
+ }
+
+ if ($input->hasParameterOption('--no-debug', true)) {
+ putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0');
+ }
+
+ return $this->input = $input;
+ }
+}
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/.env b/src/Symfony/Component/Runtime/Tests/phpt/.env
new file mode 100644
index 0000000000000..1853ef1741a1c
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/.env
@@ -0,0 +1 @@
+SOME_VAR=foo_bar
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/application.php b/src/Symfony/Component/Runtime/Tests/phpt/application.php
new file mode 100644
index 0000000000000..cbe96ed421dfc
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/application.php
@@ -0,0 +1,21 @@
+setCode(function (InputInterface $input, OutputInterface $output) use ($context) {
+ $output->write('OK Application '.$context['SOME_VAR']);
+ });
+
+ $app = new Application();
+ $app->add($command);
+ $app->setDefaultCommand('go', true);
+
+ return $app;
+};
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/application.phpt b/src/Symfony/Component/Runtime/Tests/phpt/application.phpt
new file mode 100644
index 0000000000000..e8e685f0e2f48
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/application.phpt
@@ -0,0 +1,12 @@
+--TEST--
+Test Application
+--INI--
+display_errors=1
+--FILE--
+
+--EXPECTF--
+OK Application foo_bar
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/autoload.php b/src/Symfony/Component/Runtime/Tests/phpt/autoload.php
new file mode 100644
index 0000000000000..d4141e182fe9a
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/autoload.php
@@ -0,0 +1,24 @@
+ __DIR__,
+];
+
+if (file_exists(dirname(__DIR__, 2).'/vendor/autoload.php')) {
+ if (true === (require_once dirname(__DIR__, 2).'/vendor/autoload.php') || empty($_SERVER['SCRIPT_FILENAME'])) {
+ return;
+ }
+
+ $app = require $_SERVER['SCRIPT_FILENAME'];
+ $runtime = new SymfonyRuntime($_SERVER['APP_RUNTIME_OPTIONS']);
+ $app = $runtime->resolve($app)();
+ exit($runtime->start($app)());
+}
+
+if (!file_exists(dirname(__DIR__, 6).'/vendor/autoload_runtime.php')) {
+ throw new LogicException('Autoloader not found.');
+}
+
+require dirname(__DIR__, 6).'/vendor/autoload_runtime.php';
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/command.php b/src/Symfony/Component/Runtime/Tests/phpt/command.php
new file mode 100644
index 0000000000000..42d66c1c98462
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/command.php
@@ -0,0 +1,15 @@
+setCode(function () use ($output, $context) {
+ $output->write('OK Command '.$context['SOME_VAR']);
+ });
+
+ return $command;
+};
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/command.phpt b/src/Symfony/Component/Runtime/Tests/phpt/command.phpt
new file mode 100644
index 0000000000000..8d1beeef3f34d
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/command.phpt
@@ -0,0 +1,12 @@
+--TEST--
+Test Command
+--INI--
+display_errors=1
+--FILE--
+
+--EXPECTF--
+OK Command foo_bar
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/command_list.php b/src/Symfony/Component/Runtime/Tests/phpt/command_list.php
new file mode 100644
index 0000000000000..0f99e3d066c41
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/command_list.php
@@ -0,0 +1,18 @@
+setVersion('1.2.3');
+ $app->setName('Hello console');
+ $command->setDescription('Hello description ');
+ $command->setName('my_command');
+
+ $app->add($runtime->resolve(require __DIR__.'/command.php')());
+
+ return $app;
+};
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/command_list.phpt b/src/Symfony/Component/Runtime/Tests/phpt/command_list.phpt
new file mode 100644
index 0000000000000..dce7079951361
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/command_list.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test "list" Command
+--INI--
+display_errors=1
+--FILE--
+
+--EXPECTF--
+Hello console 1.2.3
+
+Usage:
+ command [options] [arguments]
+
+Options:
+ -h, --help Display this help message
+ -q, --quiet Do not output any message
+ -V, --version Display this application version
+ --ansi Force ANSI output
+ --no-ansi Disable ANSI output
+ -n, --no-interaction Do not ask any interactive question
+ -e, --env=ENV The Environment name. [default: "prod"]
+ --no-debug Switches off debug mode.
+ -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
+
+Available commands:
+ help Displays help for a command
+ list Lists commands
+ my_command Hello description
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/kernel-loop.php b/src/Symfony/Component/Runtime/Tests/phpt/kernel-loop.php
new file mode 100644
index 0000000000000..ce47d93315913
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/kernel-loop.php
@@ -0,0 +1,25 @@
+ __DIR__]) extends SymfonyRuntime {
+ public function start(object $kernel): StartedAppInterface
+ {
+ return new ClosureStarted(static function () use ($kernel): int {
+ $kernel->handle(new Request())->send();
+ echo "\n";
+ $kernel->handle(new Request())->send();
+ echo "\n";
+
+ return 0;
+ });
+ }
+};
+
+$kernel = $runtime->resolve(require __DIR__.'/kernel.php')();
+echo $runtime->start($kernel)();
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/kernel-loop.phpt b/src/Symfony/Component/Runtime/Tests/phpt/kernel-loop.phpt
new file mode 100644
index 0000000000000..550645fb7be29
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/kernel-loop.phpt
@@ -0,0 +1,14 @@
+--TEST--
+Test HttpKernelInterface
+--INI--
+display_errors=1
+--FILE--
+
+--EXPECTF--
+OK Kernel foo_bar
+OK Kernel foo_bar
+0
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/kernel.php b/src/Symfony/Component/Runtime/Tests/phpt/kernel.php
new file mode 100644
index 0000000000000..ed5a2feb3a7f3
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/kernel.php
@@ -0,0 +1,23 @@
+var = $var;
+ }
+
+ public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = true)
+ {
+ return new Response('OK Kernel '.$this->var);
+ }
+ };
+};
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/kernel.phpt b/src/Symfony/Component/Runtime/Tests/phpt/kernel.phpt
new file mode 100644
index 0000000000000..e739eb092477e
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/kernel.phpt
@@ -0,0 +1,12 @@
+--TEST--
+Test HttpKernelInterface
+--INI--
+display_errors=1
+--FILE--
+
+--EXPECTF--
+OK Kernel foo_bar
diff --git a/src/Symfony/Component/Runtime/Tests/phpt/request.php b/src/Symfony/Component/Runtime/Tests/phpt/request.php
new file mode 100644
index 0000000000000..6bc25ef408a06
--- /dev/null
+++ b/src/Symfony/Component/Runtime/Tests/phpt/request.php
@@ -0,0 +1,10 @@
+
+--EXPECTF--
+OK Request foo_bar
diff --git a/src/Symfony/Component/Runtime/composer.json b/src/Symfony/Component/Runtime/composer.json
new file mode 100644
index 0000000000000..1018077acfb60
--- /dev/null
+++ b/src/Symfony/Component/Runtime/composer.json
@@ -0,0 +1,45 @@
+{
+ "name": "symfony/runtime",
+ "type": "composer-plugin",
+ "description": "Enables decoupling PHP apps from global state",
+ "homepage": "https://symfony.com",
+ "license" : "MIT",
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "require": {
+ "php": ">=7.2.5",
+ "composer-plugin-api": "^1.0|^2.0",
+ "symfony/polyfill-php80": "^1.15"
+ },
+ "require-dev": {
+ "composer/composer": "^1.0.2|^2.0",
+ "symfony/console": "^4.4|^5",
+ "symfony/dotenv": "^5.1",
+ "symfony/http-foundation": "^4.4|^5",
+ "symfony/http-kernel": "^4.4|^5"
+ },
+ "conflict": {
+ "symfony/dotenv": "<5.1"
+ },
+ "autoload": {
+ "psr-4": { "Symfony\\Component\\Runtime\\": "" },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "minimum-stability": "dev",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.2-dev"
+ },
+ "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin"
+ }
+}
diff --git a/src/Symfony/Component/Runtime/phpunit.xml.dist b/src/Symfony/Component/Runtime/phpunit.xml.dist
new file mode 100644
index 0000000000000..7b2c19ae05cec
--- /dev/null
+++ b/src/Symfony/Component/Runtime/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+