├── .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 |
--------------------------------------------------------------------------------