Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ All notable changes are documented here. The format is based on [Keep a Changelo
- Added `Crest\Process\Runner`, the seam through which commands run external programs, with `ShellRunner` as the default. A missing program or working directory is reported as a crest error. [#8](https://github.com/phalcon/crest/issues/8)
- Added `Crest\Generator\ClassName::namespace()`, validating a namespace with the same identifier rule as a class name. [#8](https://github.com/phalcon/crest/issues/8)
- Added `Crest\Command\Make\NamedArtifactCommand`, the base of `make:command`, `make:middleware`, `make:provider` and `make:responder`. The four commands repeated the same `handle()` and `define()`; each now gives only its key, its suffix, its description, an example name and the instructions it prints after the file is written. The base declares the `name` argument and the `--force` option, because `handle()` reads both, so a new generator cannot leave them out.
- Added `serve` (alias `server`), running PHP's built-in web server in the project root with the router script that `new` writes: `php -S 127.0.0.1:8080 -t public .htrouter.php`, with the same router and document root as the generated container. The port is `--port`, else `APP_PORT` from the environment or the project `.env`, else 8080, the order that docker compose uses. It finds the root from a subdirectory through the nearest `crest.php`, and it does not need the crest in `vendor/`. It stops before PHP starts when `.htrouter.php` or `vendor/autoload.php` is missing. `new` and the generated README now print `crest serve` for the host way. [#10](https://github.com/phalcon/crest/issues/10)

### Changed

Expand Down
29 changes: 28 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ for the same listing from the tool itself.
| `make:responder` | create an ADR responder |
| `new` | create an ADR project |
| `route:list` | every route the application answers |
| `serve` (`server`) | start PHP's built-in web server for the project |
| `stub:publish` | copy packaged stubs into the project for editing |
| `up` | start the project containers |

Expand Down Expand Up @@ -55,6 +56,29 @@ next steps:
`crest down` stops and removes the containers. `up --build` rebuilds the image
first, and `down --volumes` also removes the named volumes.

Without docker, run `composer install`, then `crest serve`. It runs
`php -S 127.0.0.1:8080 -t public .htrouter.php` in the project root, with the
same router and document root as the container. The port is `--port`, else
`APP_PORT` from the environment, else `APP_PORT` in the project `.env`, else
8080. docker compose reads `APP_PORT` in the same order. `serve` reads `.env`
as docker compose does: the last `APP_PORT` line wins, and `export`,
`APP_PORT: <port>`, quotes and `#` comments are allowed. It does not expand
`${...}`. Such a value stops `serve` with an error. `serve` finds the root
from a subdirectory, and it does not need the crest in `vendor/`, so the
global crest runs it. It stops before PHP starts if `.htrouter.php` or
`vendor/autoload.php` is missing.

The server uses the PHP that runs crest. PHP options on the command line, for
example `-d extension=phalcon.so`, do not reach it. Put such settings in
`php.ini`.

Press Ctrl+C to stop the server. If the server continues to run, for example
after crest was stopped in a different way, stop its PHP process:

pkill -f -- '-S 127.0.0.1:8080'

Use your port if it is not 8080.

`new`, `up`, `down` and `install` run before the project has a `vendor/`. Run
them with a crest outside the project, for example one that you install with
`composer global require phalcon/crest`.
Expand All @@ -78,7 +102,10 @@ publish with no name leaves the project stubs out, because they do nothing
inside a project.

You can change each project stub on its own, but `project-front`,
`project-config` and `project-index` must agree with each other. Do not change
`project-config` and `project-index` must agree with each other. Also,
`project-dockerfile`, `project-compose` and `project-env` must agree with
`serve`: keep `-t public .htrouter.php` in the `CMD`, and keep `APP_PORT`,
with the default 8080, as the port variable. Do not change
the name of the front controller class `AppFront` or the paths of the
generated files, because `new` does not read them from the stubs. A published
copy must use only the placeholders of the packaged copy. If a placeholder has
Expand Down
3 changes: 3 additions & 0 deletions resources/infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
// cannot return false on any path reaching the cast.
"Crest\\Generator\\Stub::render",
"Crest\\Project\\Config::psr4Map",
"Crest\\Command\\ServeCommand::envValue",
// Same guarantee from further off: sources() only ever yields
// paths it has confirmed with is_file(), or glob() results.
"Crest\\Command\\Stub\\PublishCommand::handle",
Expand All @@ -41,6 +42,8 @@
// the working directory cannot be read, which the suite
// cannot cause.
"Crest\\Command\\NewCommand::parent",
// The same reason as NewCommand::parent.
"Crest\\Command\\ServeCommand::root",
// Environment branch. See the note on Identical below.
"Crest\\Command\\AboutCommand::phalcon"
]
Expand Down
3 changes: 2 additions & 1 deletion resources/stubs/adr/project-htrouter.stub
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<?php

/**
* Router for PHP's built-in server: php -S localhost:8080 -t public .htrouter.php
* Router for PHP's built-in server. crest serve and the container
* (resources/docker/Dockerfile) use it.
* A request for a real file under public/ gets that file. All other requests
* go to the front controller.
*/
Expand Down
2 changes: 1 addition & 1 deletion resources/stubs/adr/project-readme.stub
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ With docker:
With PHP and composer on the host:

composer install
php -S localhost:8080 -t public .htrouter.php
crest serve

Then open http://localhost:8080/.

Expand Down
5 changes: 2 additions & 3 deletions src/Command/NewCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ final class NewCommand extends Command
Stub::PROJECT_PREFIX . 'config' => 'crest.php',
Stub::PROJECT_PREFIX . 'env' => '.env',
Stub::PROJECT_PREFIX . 'gitignore' => '.gitignore',
Stub::PROJECT_PREFIX . 'htrouter' => '.htrouter.php',
Stub::PROJECT_PREFIX . 'htrouter' => ServeCommand::ROUTER,
Stub::PROJECT_PREFIX . 'readme' => 'README.md',
Stub::PROJECT_PREFIX . 'compose' => 'docker-compose.yml',
Stub::PROJECT_PREFIX . 'dockerfile' => 'resources/docker/Dockerfile',
Expand Down Expand Up @@ -320,8 +320,7 @@ private function report(Output $output, string $shown): void
$output->line();
$output->line($cd);
$output->line(' composer install');
// Until `crest serve` exists, the host way names the server directly.
$output->line(' php -S localhost:8080 -t public .htrouter.php');
$output->line(' crest serve');
$output->line();
$output->line(sprintf('Then GET / answers from %s/%s.php', self::ACTION_PATH, self::SEED));
}
Expand Down
231 changes: 231 additions & 0 deletions src/Command/ServeCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<?php

/**
* This file is part of the Phalcon Crest.
*
* (c) Phalcon Team <team@phalcon.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Crest\Command;

use Crest\Console\Command\Command;
use Crest\Console\Exceptions\Exception;
use Crest\Console\Input;
use Crest\Console\Output;
use Crest\Console\Parsing\Definition;
use Crest\Process\Runner;
use Crest\Process\ShellRunner;
use Crest\Project\Locator;

use function count;
use function dirname;
use function file_get_contents;
use function getcwd;
use function getenv;
use function is_file;
use function preg_match;
use function preg_match_all;
use function rtrim;
use function sprintf;

use const PHP_BINARY;

/**
* Starts PHP's built-in web server for the project. It uses the router
* script that `new` writes. The router and the document root are the same
* as in the CMD of the generated Dockerfile. Thus `serve` and `up` run the
* same application.
*
* It extends the console Command, not ProjectCommand. It does not read
* crest.php, and it does not load the project's vendor/: the server runs
* the project in a child process, where public/index.php loads the project
* autoloader. Thus a global crest can run it.
*/
final class ServeCommand extends Command
{
/**
* The router script, relative to the project root. NewCommand writes the
* file with this name.
*/
public const ROUTER = '.htrouter.php';

/**
* The composer autoloader, relative to the project root. The
* public/index.php that `new` writes loads it.
*/
private const AUTOLOADER = 'vendor/autoload.php';

/**
* The document root, relative to the project root.
*/
private const DOCUMENT_ROOT = 'public';

/**
* The variables file of docker compose, relative to the project root.
*/
private const ENV_FILE = '.env';

/**
* Only this machine can connect. The container listens on 0.0.0.0. Not
* localhost: on some hosts localhost gives ::1 first, and then PHP
* listens on IPv6 only.
*/
private const HOST = '127.0.0.1';

private const PORT_DEFAULT = 8080;

/**
* An APP_PORT line of .env, as docker compose reads it: an optional
* `export`, then '=' or ':'. Spaces around the name, the separator and
* the value are ignored. A quoted value ends at its quote. An unquoted
* value ends before ' #'. A ' #' comment after the value is ignored.
*/
private const PORT_LINE = '/^\h*(?:export\h+)?APP_PORT\h*[=:]\h*(?|"([^"]*)"|\'([^\']*)\'|(.*?))\h*(?: #.*)?\r?$/m';

private const PORT_MAX = 65535;

/**
* The variable that docker-compose.yml reads for the published port.
*/
private const PORT_VARIABLE = 'APP_PORT';

private readonly Runner $runner;

/**
* The default lets the kernel's `new $class()` work. A test gives a fake
* runner, and checks the exact argv without a server.
*/
public function __construct(?Runner $runner = null)
{
$this->runner = $runner ?? new ShellRunner();
}

public function define(): Definition
{
return Definition::for('serve', "Start PHP's built-in web server for the project")
->option('port=s', 'Port, 1 to 65535. Default: APP_PORT, then 8080');
}

/**
* Checks the router, the autoloader and the port first. An error then
* stops the command before PHP starts. PHP itself reports a port that is
* in use and a missing public/ directory.
*/
public function handle(Input $input, Output $output): int
{
$root = $this->root($input);
$router = $root . '/' . self::ROUTER;

if (false === is_file($router)) {
throw new Exception(
sprintf('%s was not found; serve uses the router script that crest new writes', $router)
);
}

$autoloader = $root . '/' . self::AUTOLOADER;

if (false === is_file($autoloader)) {
throw new Exception(sprintf('%s was not found; run composer install first', $autoloader));
}

$port = $this->port($input, $root);

// The PHP that runs crest, not the first php on the PATH.
return $this->runner->run(
[PHP_BINARY, '-S', self::HOST . ':' . $port, '-t', self::DOCUMENT_ROOT, self::ROUTER],
$root
);
}

/**
* The APP_PORT value in the .env file. Empty if there is no file or no
* APP_PORT line. The last line wins, as in docker compose.
*/
private function envValue(string $file): string
{
if (
true === is_file($file)
&& 0 < preg_match_all(self::PORT_LINE, (string) file_get_contents($file), $matches)
) {
$values = $matches[1];

return $values[count($values) - 1];
}

return '';
}

/**
* --port, if the user gave it. If not, APP_PORT from the environment,
* then APP_PORT from .env, then PORT_DEFAULT. docker compose uses the
* same order, so that serve and up use the same port. An empty APP_PORT
* reads as absent, as ${APP_PORT:-8080} in docker-compose.yml reads it.
*/
private function port(Input $input, string $root): int
{
$option = $input->optionStringOrNull('port');

if (null !== $option) {
return $this->portNumber($option, '');
}

$variable = (string) getenv(self::PORT_VARIABLE);

if ('' !== $variable) {
return $this->portNumber($variable, sprintf(' (%s in the environment)', self::PORT_VARIABLE));
}

$file = $root . '/' . self::ENV_FILE;
$value = $this->envValue($file);

if ('' !== $value) {
return $this->portNumber($value, sprintf(' (%s in %s)', self::PORT_VARIABLE, $file));
}

return self::PORT_DEFAULT;
}

/**
* Digits only, from 1 to PORT_MAX. The pattern ends with \z, not $,
* because $ also accepts a trailing newline. The int goes into the argv,
* so 0080 gives 80. The source tells the user where the value came from.
* It is empty for --port.
*/
private function portNumber(string $value, string $source): int
{
$port = (int) $value;

if (0 === preg_match('/^\d+\z/', $value) || $port < 1 || $port > self::PORT_MAX) {
throw new Exception(
sprintf("'%s' is not a port%s; expected an integer from 1 to %d", $value, $source, self::PORT_MAX)
);
}

return $port;
}

/**
* --directory, if the user gave one. An empty value reads as absent, as
* it does for `new` and `up`. If not, the directory of the nearest
* crest.php above the working directory, so that serve works from a
* subdirectory. If there is no crest.php, the working directory.
*/
private function root(Input $input): string
{
$directory = $input->optionString('directory');

if ('' !== $directory) {
return rtrim($directory, '/');
}

$cwd = (string) getcwd();
$file = Locator::locate($cwd);

return null === $file ? $cwd : dirname($file);
}
}
2 changes: 2 additions & 0 deletions src/Commands.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Crest\Command\Make\ResponderCommand;
use Crest\Command\NewCommand;
use Crest\Command\Route\ListCommand as RouteListCommand;
use Crest\Command\ServeCommand;
use Crest\Command\Stub\PublishCommand as StubPublishCommand;
use Crest\Command\UpCommand;
use Crest\Console\Registry;
Expand Down Expand Up @@ -73,6 +74,7 @@ public static function registry(): Registry
->add('make:responder', ResponderCommand::class)
->add('new', NewCommand::class)
->add('route:list', RouteListCommand::class)
->add('serve', ServeCommand::class, 'server')
->add('stub:publish', StubPublishCommand::class)
->add('up', UpCommand::class)
->withDiscovery(self::KEY);
Expand Down
5 changes: 3 additions & 2 deletions tests/Unit/Command/NewCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ public function testTheClosingOutputShowsBothWaysToRunIt(): void
. PHP_EOL
. ' cd ' . $target . PHP_EOL
. ' composer install' . PHP_EOL
. ' php -S localhost:8080 -t public .htrouter.php' . PHP_EOL
. ' crest serve' . PHP_EOL
. PHP_EOL
. 'Then GET / answers from src/Action/Get.php' . PHP_EOL,
$this->readStdout()
Expand Down Expand Up @@ -597,7 +597,8 @@ public function testTheRouterScriptIsRendered(): void
"<?php\n"
. "\n"
. "/**\n"
. " * Router for PHP's built-in server: php -S localhost:8080 -t public .htrouter.php\n"
. " * Router for PHP's built-in server. crest serve and the container\n"
. " * (resources/docker/Dockerfile) use it.\n"
. " * A request for a real file under public/ gets that file. All other requests\n"
. " * go to the front controller.\n"
. " */\n"
Expand Down
Loading
Loading