├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── pull_request_template.md └── workflows │ └── ci.yml ├── .phpcs.xml.dist ├── LICENSE ├── README.md ├── bin └── bref-dev-server ├── composer.json ├── psalm.xml └── src ├── Handler.php ├── NotFound.php ├── ResponseEmitter.php ├── Router.php └── server-handler.php /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all, **thank you** for contributing! 4 | 5 | Here are a few rules to follow in order to ease code reviews and merging: 6 | 7 | - follow the coding standard of the project 8 | - run the test suite 9 | - write (or update) tests when applicable 10 | - write documentation for new features 11 | - use [commit messages that make sense](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) 12 | 13 | When creating your pull request on GitHub, please write a description which gives the context and/or explains why you are creating it. 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: mnapoli # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: 7 | branches: ['*'] 8 | schedule: 9 | - cron: '0 0 * * *' 10 | 11 | jobs: 12 | 13 | tests: 14 | name: Tests - PHP ${{ matrix.php }} ${{ matrix.dependency-version }} 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 15 17 | strategy: 18 | matrix: 19 | php: [ '8.0' ] 20 | dependency-version: [ '' ] 21 | include: 22 | - php: '8.0' 23 | dependency-version: '--prefer-lowest' 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v2 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | tools: composer:v2 32 | coverage: none 33 | - name: Cache Composer dependencies 34 | uses: actions/cache@v2 35 | with: 36 | path: ~/.composer/cache 37 | key: php-${{ matrix.php }}-composer-locked-${{ hashFiles('composer.lock') }} 38 | restore-keys: php-${{ matrix.php }}-composer-locked- 39 | - name: Install PHP dependencies 40 | run: composer update ${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-progress --no-suggest 41 | - name: PHPUnit 42 | run: vendor/bin/phpunit 43 | 44 | cs: 45 | name: Coding standards 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout 49 | uses: actions/checkout@v2 50 | - name: Setup PHP 51 | uses: shivammathur/setup-php@v2 52 | with: 53 | php-version: 8.0 54 | tools: composer:v2, cs2pr 55 | coverage: none 56 | - name: Cache Composer dependencies 57 | uses: actions/cache@v2 58 | with: 59 | path: ~/.composer/cache 60 | key: php-composer-locked-${{ hashFiles('composer.lock') }} 61 | restore-keys: php-composer-locked- 62 | - name: Install PHP dependencies 63 | run: composer install --no-interaction --no-progress --no-suggest 64 | - name: PHP CodeSniffer 65 | run: vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr 66 | -------------------------------------------------------------------------------- /.phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src 6 | tests 7 | 8 | 9 | 10 | 11 | 0 12 | 13 | 14 | 15 | 16 | 0 17 | 18 | 19 | 0 20 | 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Matthieu Napoli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Development web server for serverless-native PHP web apps. 2 | 3 | ## Why? 4 | 5 | This web server is meant for HTTP applications implemented without framework, using API Gateway as the router and PSR-15 controllers. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | composer require --dev bref/dev-server 11 | ``` 12 | 13 | ## Usage 14 | 15 | Run the webserver with: 16 | 17 | ```bash 18 | vendor/bin/bref-dev-server 19 | ``` 20 | 21 | The application will be available at [http://localhost:8000/](http://localhost:8000/). 22 | 23 | Routes will be parsed from `serverless.yml` in the current directory. 24 | 25 | ### Assets 26 | 27 | By default, static assets are served from the current directory. 28 | 29 | To customize that, use the `--assets` option. For example to serve static files from the `public/` directory: 30 | 31 | ```bash 32 | vendor/bin/bref-dev-server --assets=public 33 | ``` 34 | -------------------------------------------------------------------------------- /bin/bref-dev-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | command('run [-a|--assets=]', function (OutputInterface $output, ?string $assets = null) { 20 | $handler = __DIR__ . '/../src/server-handler.php'; 21 | $assetsDirectory = $assets ?: getcwd(); 22 | $output->writeln("Serving PHP from serverless.yml routes"); 23 | $output->writeln("Serving assets from $assetsDirectory/"); 24 | 25 | $server = new Process(['php', '-S', '0.0.0.0:8000', '-t', $assetsDirectory, $handler]); 26 | $server->setTimeout(null); 27 | $server->setTty(true); 28 | $server->setEnv([ 29 | 'PHP_CLI_SERVER_WORKERS' => 2, 30 | Handler::ASSETS_DIRECTORY_VARIABLE => $assetsDirectory, 31 | ]); 32 | 33 | $server->run(); 34 | 35 | exit($server->getExitCode()); 36 | })->descriptions('Run the development server', [ 37 | '--assets' => 'The directory where static assets can be found. By default it is the current directory.', 38 | ]); 39 | 40 | $app->setDefaultCommand('run'); 41 | $app->run(); 42 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bref/dev-server", 3 | "description": "Local development server for serverless web apps", 4 | "keywords": ["bref"], 5 | "license": "MIT", 6 | "type": "library", 7 | "bin": [ 8 | "bin/bref-dev-server" 9 | ], 10 | "autoload": { 11 | "psr-4": { 12 | "Bref\\DevServer\\": "src/" 13 | } 14 | }, 15 | "autoload-dev": { 16 | "psr-4": { 17 | "Bref\\DevServer\\Test\\": "tests/" 18 | } 19 | }, 20 | "require": { 21 | "php": ">=8.0", 22 | "bref/bref": "^1.0 || ^2.0", 23 | "filp/whoops": "^2.5", 24 | "mnapoli/silly": "^1.5", 25 | "nyholm/psr7": "^1.0", 26 | "nyholm/psr7-server": "^1.0", 27 | "psr/http-message": "^1.0", 28 | "psr/http-server-handler": "^1.0", 29 | "psr/http-server-middleware": "^1.0", 30 | "symfony/console": "^4.0|^5.0|^6.0", 31 | "symfony/process": "^4.0|^5.0|^6.0", 32 | "symfony/yaml": "^4.0|^5.0|^6.0" 33 | }, 34 | "require-dev": { 35 | "phpunit/phpunit": "^9.0", 36 | "mnapoli/hard-mode": "^0.3.0", 37 | "vimeo/psalm": "^4.3" 38 | }, 39 | "config": { 40 | "sort-packages": true, 41 | "allow-plugins": { 42 | "dealerdirect/phpcodesniffer-composer-installer": true 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Handler.php: -------------------------------------------------------------------------------- 1 | pushHandler(new PrettyPageHandler); 33 | $whoops->register(); 34 | 35 | $container = Bref::getContainer(); 36 | 37 | $serverlessFile = getcwd() . '/serverless.yml'; 38 | if (! file_exists($serverlessFile)) { 39 | throw new RuntimeException('No serverless.yml file was found in the current directory. This dev server needs a serverless.yml to discover the API Gateway routes.'); 40 | } 41 | $serverlessConfig = Yaml::parseFile($serverlessFile, Yaml::PARSE_CUSTOM_TAGS); 42 | $router = Router::fromServerlessConfig($serverlessConfig); 43 | 44 | $request = $this->requestFromGlobals(); 45 | [$handler, $request] = $router->match($request); 46 | $controller = $handler ? $container->get($handler) : new NotFound; 47 | $response = $controller->handle($request); 48 | (new ResponseEmitter)->emit($response); 49 | 50 | return null; 51 | } 52 | 53 | private function requestFromGlobals(): ServerRequestInterface 54 | { 55 | $psr17Factory = new Psr17Factory; 56 | $requestFactory = new ServerRequestCreator( 57 | $psr17Factory, 58 | $psr17Factory, 59 | $psr17Factory, 60 | $psr17Factory 61 | ); 62 | return $requestFactory->fromGlobals(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/NotFound.php: -------------------------------------------------------------------------------- 1 | getUri()->getPath(); 18 | 19 | return new Response(404, [], "Route '$url' not found in serverless.yml"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ResponseEmitter.php: -------------------------------------------------------------------------------- 1 | emitHeaders($response); 15 | $this->emitStatusLine($response); 16 | echo $response->getBody(); 17 | } 18 | 19 | private function emitStatusLine(ResponseInterface $response): void 20 | { 21 | $reasonPhrase = $response->getReasonPhrase(); 22 | $statusCode = $response->getStatusCode(); 23 | 24 | header(sprintf( 25 | 'HTTP/%s %d%s', 26 | $response->getProtocolVersion(), 27 | $statusCode, 28 | ($reasonPhrase ? ' ' . $reasonPhrase : '') 29 | ), true, $statusCode); 30 | } 31 | 32 | private function emitHeaders(ResponseInterface $response): void 33 | { 34 | $statusCode = $response->getStatusCode(); 35 | 36 | foreach ($response->getHeaders() as $header => $values) { 37 | $name = $this->filterHeader($header); 38 | $first = $name !== 'Set-Cookie'; 39 | foreach ($values as $value) { 40 | header(sprintf( 41 | '%s: %s', 42 | $name, 43 | $value 44 | ), $first, $statusCode); 45 | $first = false; 46 | } 47 | } 48 | } 49 | 50 | private function filterHeader(string $header): string 51 | { 52 | return ucwords($header, '-'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | */ 55 | private array $routes; 56 | 57 | /** 58 | * @param array $routes 59 | */ 60 | public function __construct(array $routes) 61 | { 62 | $this->routes = $routes; 63 | } 64 | 65 | /** 66 | * @return array{0: ?string, 1: ServerRequestInterface} 67 | */ 68 | public function match(ServerRequestInterface $request): array 69 | { 70 | foreach ($this->routes as $pattern => $handler) { 71 | // Catch-all 72 | if ($pattern === '*') return [$handler, $request]; 73 | 74 | [$httpMethod, $pathPattern] = explode(' ', $pattern); 75 | if ($this->matchesMethod($request, $httpMethod) && $this->matchesPath($request, $pathPattern)) { 76 | $request = $this->addPathParameters($request, $pathPattern); 77 | 78 | return [$handler, $request]; 79 | } 80 | } 81 | 82 | // No route matched 83 | return [null, $request]; 84 | } 85 | 86 | private function matchesMethod(ServerRequestInterface $request, string $method): bool 87 | { 88 | $method = strtolower($method); 89 | 90 | return ($method === '*') || ($method === strtolower($request->getMethod())); 91 | } 92 | 93 | private function matchesPath(ServerRequestInterface $request, string $pathPattern): bool 94 | { 95 | $requestPath = $request->getUri()->getPath(); 96 | 97 | // No path parameter 98 | if (! str_contains($pathPattern, '{')) { 99 | return $requestPath === $pathPattern; 100 | } 101 | 102 | $pathRegex = $this->patternToRegex($pathPattern); 103 | 104 | return preg_match($pathRegex, $requestPath) === 1; 105 | } 106 | 107 | private function addPathParameters(ServerRequestInterface $request, mixed $pathPattern): ServerRequestInterface 108 | { 109 | $requestPath = $request->getUri()->getPath(); 110 | 111 | // No path parameter 112 | if (! str_contains($pathPattern, '{')) { 113 | return $request; 114 | } 115 | 116 | $pathRegex = $this->patternToRegex($pathPattern); 117 | preg_match($pathRegex, $requestPath, $matches); 118 | foreach ($matches as $name => $value) { 119 | $request = $request->withAttribute($name, $value); 120 | } 121 | 122 | return $request; 123 | } 124 | 125 | private function patternToRegex(string $pathPattern): string 126 | { 127 | // Match to find all the parameter names 128 | $matchRegex = '#^' . preg_replace('/{[^}]+}/', '([^/]+)', $pathPattern) . '$#'; 129 | preg_match($matchRegex, $pathPattern, $matches); 130 | // Ignore the global match of the string 131 | unset($matches[0]); 132 | 133 | /* 134 | * We will replace all parameter paths with a *name* group. 135 | * Essentially: 136 | * - `/{root}` will be replaced to `/(?[^/]+)` (i.e. `([^/]+)` named "root") 137 | */ 138 | $patterns = []; 139 | $replacements = []; 140 | foreach ($matches as $position => $parameterName) { 141 | $patterns[$position] = "#$parameterName#"; 142 | // Remove `{` and `}` delimiters 143 | $parameterName = substr($parameterName, 1, -1); 144 | // The `?<$parameterName>` syntax lets us name the capturing group 145 | $replacements[$position] = "(?<$parameterName>[^/]+)"; 146 | } 147 | 148 | $regex = preg_replace($patterns, $replacements, $pathPattern); 149 | 150 | return '#^' . $regex . '$#'; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/server-handler.php: -------------------------------------------------------------------------------- 1 | handleRequest(); 12 | --------------------------------------------------------------------------------