├── src
├── ConsoleRuntime
│ ├── CommandFailed.php
│ └── Main.php
├── Runtime
│ ├── HandlerNotFound.php
│ ├── ResponseTooBig.php
│ ├── Invoker.php
│ ├── FileHandlerLocator.php
│ ├── ColdStartTracker.php
│ └── LambdaRuntime.php
├── FpmRuntime
│ ├── FastCgi
│ │ ├── FastCgiCommunicationFailed.php
│ │ ├── FastCgiRequest.php
│ │ └── Timeout.php
│ ├── Main.php
│ └── FpmHandler.php
├── Event
│ ├── Handler.php
│ ├── Kafka
│ │ ├── KafkaHandler.php
│ │ ├── KafkaEvent.php
│ │ └── KafkaRecord.php
│ ├── LambdaEvent.php
│ ├── Kinesis
│ │ ├── KinesisHandler.php
│ │ ├── KinesisEvent.php
│ │ └── KinesisRecord.php
│ ├── S3
│ │ ├── S3Handler.php
│ │ ├── Bucket.php
│ │ ├── BucketObject.php
│ │ ├── S3Event.php
│ │ └── S3Record.php
│ ├── Sns
│ │ ├── SnsHandler.php
│ │ ├── MessageAttribute.php
│ │ ├── SnsEvent.php
│ │ └── SnsRecord.php
│ ├── DynamoDb
│ │ ├── DynamoDbHandler.php
│ │ ├── DynamoDbEvent.php
│ │ └── DynamoDbRecord.php
│ ├── EventBridge
│ │ ├── EventBridgeHandler.php
│ │ └── EventBridgeEvent.php
│ ├── ApiGateway
│ │ ├── WebsocketHandler.php
│ │ └── WebsocketEvent.php
│ ├── InvalidLambdaEvent.php
│ ├── Http
│ │ ├── Psr15Handler.php
│ │ ├── HttpHandler.php
│ │ ├── HttpResponse.php
│ │ ├── Psr7Bridge.php
│ │ └── HttpRequestEvent.php
│ └── Sqs
│ │ ├── SqsEvent.php
│ │ ├── SqsHandler.php
│ │ └── SqsRecord.php
├── Context
│ ├── ContextBuilder.php
│ └── Context.php
├── FunctionRuntime
│ └── Main.php
├── Listener
│ ├── BrefEventSubscriber.php
│ └── EventDispatcher.php
├── LazySecretsLoader.php
├── bref-local
├── Cli
│ └── init.php
└── Bref.php
├── template
├── function
│ ├── index.php
│ └── serverless.yml
├── http
│ ├── serverless.yml
│ └── index.php
└── symfony
│ └── serverless.yml
├── README.md
├── package.json
├── SECURITY.md
├── plugin
├── layers.js
├── local.js
├── secrets.js
└── run-console.js
├── LICENSE
├── serverless.yml
├── composer.json
├── bref
├── layers.json
└── index.js
/src/ConsoleRuntime/CommandFailed.php:
--------------------------------------------------------------------------------
1 | handleKafka(new KafkaEvent($event), $context);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Event/LambdaEvent.php:
--------------------------------------------------------------------------------
1 | handleKinesis(new KinesisEvent($event), $context);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Event/S3/S3Handler.php:
--------------------------------------------------------------------------------
1 | handleS3(new S3Event($event), $context);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Event/Sns/SnsHandler.php:
--------------------------------------------------------------------------------
1 | handleSns(new SnsEvent($event), $context);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Runtime/ResponseTooBig.php:
--------------------------------------------------------------------------------
1 | handleDynamoDb(new DynamoDbEvent($event), $context);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Event/EventBridge/EventBridgeHandler.php:
--------------------------------------------------------------------------------
1 | handleEventBridge(new EventBridgeEvent($event), $context);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Event/S3/Bucket.php:
--------------------------------------------------------------------------------
1 | name = $name;
16 | $this->arn = $arn;
17 | }
18 |
19 | public function getName(): string
20 | {
21 | return $this->name;
22 | }
23 |
24 | public function getArn(): string
25 | {
26 | return $this->arn;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/template/http/serverless.yml:
--------------------------------------------------------------------------------
1 | service: app
2 |
3 | # Set your team ID if you are using Bref Cloud
4 | #bref:
5 | # team: my-team-id
6 |
7 | provider:
8 | name: aws
9 | region: us-east-1
10 |
11 | plugins:
12 | - ./vendor/bref/bref
13 |
14 | functions:
15 | api:
16 | handler: index.php
17 | description: ''
18 | runtime: php-PHP_VERSION-fpm
19 | timeout: 28 # in seconds (API Gateway has a timeout of 29 seconds)
20 | events:
21 | - httpApi: '*'
22 |
23 | # Exclude files from deployment
24 | package:
25 | patterns:
26 | - '!node_modules/**'
27 | - '!tests/**'
28 |
--------------------------------------------------------------------------------
/src/Event/ApiGateway/WebsocketHandler.php:
--------------------------------------------------------------------------------
1 | handleWebsocket(new WebsocketEvent($event), $context)->toApiGatewayFormat();
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Event/InvalidLambdaEvent.php:
--------------------------------------------------------------------------------
1 | method = $method;
18 | parent::__construct($scriptFilename, $content);
19 | }
20 |
21 | public function getRequestMethod(): string
22 | {
23 | return $this->method;
24 | }
25 |
26 | public function getServerSoftware(): string
27 | {
28 | return 'bref';
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/FpmRuntime/FastCgi/Timeout.php:
--------------------------------------------------------------------------------
1 | psr15Handler = $psr15Handler;
15 | }
16 |
17 | public function handleRequest(HttpRequestEvent $event, Context $context): HttpResponse
18 | {
19 | Psr7Bridge::cleanupUploadedFiles();
20 |
21 | $request = Psr7Bridge::convertRequest($event, $context);
22 |
23 | $response = $this->psr15Handler->handle($request);
24 |
25 | return Psr7Bridge::convertResponse($response);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | | Version | Supported |
6 | | ------- | ------------------ |
7 | | 2.x | :white_check_mark: |
8 | | 1.x | :x: |
9 |
10 | Version 1.x is no longer supported. You should upgrade to v2.
11 |
12 | If you prefer to stay on v1 and need long term support for v1 (or need help upgrading), get in touch for enterprise support: https://bref.sh/support
13 |
14 | ## Reporting a Vulnerability
15 |
16 | ⚠️ PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, SEE BELOW.
17 |
18 | If you discover a security vulnerability within this package, please send an email to security@bref.sh or open a security issue report on this page: https://github.com/brefphp/bref/security/advisories.
19 |
20 | All security vulnerabilities will be promptly addressed. Please do not disclose security-related issues publicly until a fix has been announced.
21 |
--------------------------------------------------------------------------------
/src/Runtime/Invoker.php:
--------------------------------------------------------------------------------
1 | handle($event, $context);
25 | }
26 |
27 | if (is_callable($handler)) {
28 | // The handler is a callable
29 | return $handler($event, $context);
30 | }
31 |
32 | throw new Exception('The lambda handler must be a callable or implement handler interfaces');
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Event/S3/BucketObject.php:
--------------------------------------------------------------------------------
1 | key = $key;
17 | $this->size = $size;
18 | $this->versionId = $versionId;
19 | }
20 |
21 | /**
22 | * @return string A S3 key is similar to a file path.
23 | */
24 | public function getKey(): string
25 | {
26 | return $this->key;
27 | }
28 |
29 | /**
30 | * @return int Object/file size.
31 | */
32 | public function getSize(): int
33 | {
34 | return $this->size;
35 | }
36 |
37 | public function getVersionId(): ?string
38 | {
39 | return $this->versionId;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/plugin/layers.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 |
4 | /**
5 | * @param {import('./serverless').Serverless} serverless
6 | * @param {import('./serverless').Logger} log
7 | */
8 | function listLayers(serverless, log) {
9 | const region = serverless.getProvider("aws").getRegion();
10 |
11 | const json = fs.readFileSync(path.join(__dirname, '../layers.json'));
12 | const layers = JSON.parse(json.toString());
13 | log(`Layers for the ${region} region:`);
14 |
15 | log();
16 | log('Layer Version ARN');
17 | log('----------------------------------------------------------------------------------');
18 | for (const [layer, versions] of Object.entries(layers)) {
19 | const version = versions[region];
20 | const arn = `arn:aws:lambda:${region}:534081306603:layer:${layer}:${version}`;
21 | log(`${padString(layer, 12)} ${padString(version, 9)} ${arn}`);
22 | }
23 | }
24 |
25 | function padString(str, length) {
26 | return str.padEnd(length, ' ');
27 | }
28 |
29 | module.exports = {listLayers};
30 |
--------------------------------------------------------------------------------
/plugin/local.js:
--------------------------------------------------------------------------------
1 | const {spawnSync} = require('child_process');
2 | const path = require('path');
3 | const fs = require('fs');
4 |
5 | /**
6 | * @param {import('./serverless').Serverless} serverless
7 | * @param {import('./serverless').CliOptions} options
8 | */
9 | function runLocal(serverless, options) {
10 | if (options.data && options.path) {
11 | throw new serverless.classes.Error('You cannot provide both --data and --path');
12 | }
13 |
14 | let data = options.data;
15 | if (!data && options.path) {
16 | if (typeof options.path !== 'string') {
17 | throw new serverless.classes.Error('The --path option must be a string');
18 | }
19 | data = fs.readFileSync(options.path).toString();
20 | }
21 |
22 | // @ts-ignore
23 | const fn = serverless.service.getFunction(options.function);
24 |
25 | const args = [
26 | path.join(__dirname, '../src/bref-local'),
27 | fn.handler,
28 | data || '',
29 | ];
30 | spawnSync('php', args, {
31 | stdio: 'inherit',
32 | });
33 | }
34 |
35 | module.exports = {runLocal};
36 |
--------------------------------------------------------------------------------
/src/Event/Sns/MessageAttribute.php:
--------------------------------------------------------------------------------
1 | attribute = $attribute;
18 | }
19 |
20 | public function getType(): string
21 | {
22 | return $this->attribute['Type'];
23 | }
24 |
25 | public function getValue(): string
26 | {
27 | return $this->attribute['Value'];
28 | }
29 |
30 | /**
31 | * Returns the attribute original data as an array.
32 | *
33 | * Use this method if you want to access data that is not returned by a method in this class.
34 | */
35 | public function toArray(): array
36 | {
37 | return $this->attribute;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2025 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 |
--------------------------------------------------------------------------------
/src/Event/Sns/SnsEvent.php:
--------------------------------------------------------------------------------
1 | event = $event;
24 | }
25 |
26 | /**
27 | * @return SnsRecord[]
28 | */
29 | public function getRecords(): array
30 | {
31 | return array_map(function ($record): SnsRecord {
32 | try {
33 | return new SnsRecord($record);
34 | } catch (InvalidArgumentException) {
35 | throw new InvalidLambdaEvent('SNS', $this->event);
36 | }
37 | }, $this->event['Records']);
38 | }
39 |
40 | public function toArray(): array
41 | {
42 | return $this->event;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Event/Sqs/SqsEvent.php:
--------------------------------------------------------------------------------
1 | event = $event;
24 | }
25 |
26 | /**
27 | * @return SqsRecord[]
28 | */
29 | public function getRecords(): array
30 | {
31 | return array_map(function ($record): SqsRecord {
32 | try {
33 | return new SqsRecord($record);
34 | } catch (InvalidArgumentException) {
35 | throw new InvalidLambdaEvent('SQS', $this->event);
36 | }
37 | }, $this->event['Records']);
38 | }
39 |
40 | public function toArray(): array
41 | {
42 | return $this->event;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Event/Http/HttpHandler.php:
--------------------------------------------------------------------------------
1 | handleRequest($httpEvent, $context);
26 |
27 | if ($httpEvent->isFormatV2()) {
28 | return $response->toApiGatewayFormatV2($context->getAwsRequestId());
29 | }
30 |
31 | return $response->toApiGatewayFormat($httpEvent->hasMultiHeader(), $context->getAwsRequestId());
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Event/S3/S3Event.php:
--------------------------------------------------------------------------------
1 | event = $event;
26 | }
27 |
28 | /**
29 | * @return S3Record[]
30 | */
31 | public function getRecords(): array
32 | {
33 | return array_map(function ($record): S3Record {
34 | try {
35 | return new S3Record($record);
36 | } catch (InvalidArgumentException) {
37 | throw new InvalidLambdaEvent('S3', $this->event);
38 | }
39 | }, $this->event['Records']);
40 | }
41 |
42 | public function toArray(): array
43 | {
44 | return $this->event;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Event/Kinesis/KinesisEvent.php:
--------------------------------------------------------------------------------
1 | event = $event;
26 | }
27 |
28 | /**
29 | * @return KinesisRecord[]
30 | */
31 | public function getRecords(): array
32 | {
33 | return array_map(
34 | function ($record): KinesisRecord {
35 | try {
36 | return new KinesisRecord($record);
37 | } catch (InvalidArgumentException) {
38 | throw new InvalidLambdaEvent('Kinesis', $this->event);
39 | }
40 | },
41 | $this->event['Records']
42 | );
43 | }
44 |
45 | public function toArray(): array
46 | {
47 | return $this->event;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Event/Kafka/KafkaEvent.php:
--------------------------------------------------------------------------------
1 | event = $event;
28 | }
29 |
30 | /**
31 | * @return KafkaRecord[]
32 | */
33 | public function getRecords(): array
34 | {
35 | return array_map(function ($record): KafkaRecord {
36 | try {
37 | return new KafkaRecord($record);
38 | } catch (InvalidArgumentException $e) {
39 | throw new InvalidLambdaEvent('Kafka', $this->event);
40 | }
41 | }, array_merge(...array_values($this->event['records'])));
42 | }
43 |
44 | public function toArray(): array
45 | {
46 | return $this->event;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Event/Sqs/SqsHandler.php:
--------------------------------------------------------------------------------
1 | failedRecords = [];
23 |
24 | $this->handleSqs(new SqsEvent($event), $context);
25 |
26 | if (count($this->failedRecords) === 0) {
27 | return null;
28 | }
29 |
30 | $failures = array_map(
31 | function (SqsRecord $record) {
32 | return ['itemIdentifier' => $record->getMessageId()];
33 | },
34 | $this->failedRecords
35 | );
36 |
37 | return [
38 | 'batchItemFailures' => $failures,
39 | ];
40 | }
41 |
42 | final protected function markAsFailed(SqsRecord $record): void
43 | {
44 | $this->failedRecords[] = $record;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Context/ContextBuilder.php:
--------------------------------------------------------------------------------
1 | awsRequestId = '';
15 | $this->deadlineMs = 0;
16 | $this->invokedFunctionArn = '';
17 | $this->traceId = '';
18 | }
19 |
20 | public function setAwsRequestId(string $awsRequestId): void
21 | {
22 | $this->awsRequestId = $awsRequestId;
23 | }
24 |
25 | public function setDeadlineMs(int $deadlineMs): void
26 | {
27 | $this->deadlineMs = $deadlineMs;
28 | }
29 |
30 | public function setInvokedFunctionArn(string $invokedFunctionArn): void
31 | {
32 | $this->invokedFunctionArn = $invokedFunctionArn;
33 | }
34 |
35 | public function setTraceId(string $traceId): void
36 | {
37 | $this->traceId = $traceId;
38 | }
39 |
40 | public function buildContext(): Context
41 | {
42 | return new Context(
43 | $this->awsRequestId,
44 | $this->deadlineMs,
45 | $this->invokedFunctionArn,
46 | $this->traceId
47 | );
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Event/DynamoDb/DynamoDbEvent.php:
--------------------------------------------------------------------------------
1 | event = $event;
28 | }
29 |
30 | /**
31 | * @return DynamoDbRecord[]
32 | */
33 | public function getRecords(): array
34 | {
35 | return array_map(
36 | function ($record): DynamoDbRecord {
37 | try {
38 | return new DynamoDbRecord($record);
39 | } catch (InvalidArgumentException) {
40 | throw new InvalidLambdaEvent('DynamoDb', $this->event);
41 | }
42 | },
43 | $this->event['Records']
44 | );
45 | }
46 |
47 | public function toArray(): array
48 | {
49 | return $this->event;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/template/symfony/serverless.yml:
--------------------------------------------------------------------------------
1 | # Read the documentation at https://bref.sh/docs/symfony/getting-started
2 | service: symfony
3 |
4 | # Set your team ID if you are using Bref Cloud
5 | #bref:
6 | # team: my-team-id
7 |
8 | provider:
9 | name: aws
10 | # The AWS region in which to deploy (us-east-1 is the default)
11 | region: us-east-1
12 | environment:
13 | # Symfony environment variables
14 | APP_ENV: prod
15 |
16 | plugins:
17 | - ./vendor/bref/bref
18 |
19 | functions:
20 |
21 | # This function runs the Symfony website/API
22 | web:
23 | handler: public/index.php
24 | runtime: php-82-fpm
25 | timeout: 28 # in seconds (API Gateway has a timeout of 29 seconds)
26 | events:
27 | - httpApi: '*'
28 |
29 | # This function let us run console commands in Lambda
30 | console:
31 | handler: bin/console
32 | runtime: php-82-console
33 | timeout: 120 # in seconds
34 |
35 | package:
36 | patterns:
37 | # Excluded files and folders for deployment
38 | - '!assets/**'
39 | - '!node_modules/**'
40 | - '!public/build/**'
41 | - '!tests/**'
42 | - '!var/**'
43 | # If you want to include files and folders that are part of excluded folders,
44 | # add them at the end
45 | - 'var/cache/prod/**'
46 | - 'public/build/entrypoints.json'
47 | - 'public/build/manifest.json'
48 |
--------------------------------------------------------------------------------
/src/Event/Kinesis/KinesisRecord.php:
--------------------------------------------------------------------------------
1 | record = $record;
22 | }
23 |
24 | public function getApproximateArrivalTime(): DateTimeImmutable
25 | {
26 | return DateTimeImmutable::createFromFormat('U.u', (string) $this->record['kinesis']['approximateArrivalTimestamp']);
27 | }
28 |
29 | public function getData(): array
30 | {
31 | return json_decode(base64_decode($this->getRawData()), true, 512, JSON_THROW_ON_ERROR);
32 | }
33 |
34 | public function getEventName(): string
35 | {
36 | return $this->record['eventName'];
37 | }
38 |
39 | public function getPartitionKey(): string
40 | {
41 | return $this->record['kinesis']['partitionKey'];
42 | }
43 |
44 | public function getRawData(): string
45 | {
46 | return $this->record['kinesis']['data'];
47 | }
48 |
49 | public function getSequenceNumber(): string
50 | {
51 | return $this->record['kinesis']['sequenceNumber'];
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/FunctionRuntime/Main.php:
--------------------------------------------------------------------------------
1 | beforeStartup();
24 |
25 | $lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('function');
26 |
27 | $container = Bref::getContainer();
28 |
29 | try {
30 | $handler = $container->get(getenv('_HANDLER'));
31 | } catch (Throwable $e) {
32 | $lambdaRuntime->failInitialization($e, 'Runtime.NoSuchHandler');
33 | }
34 |
35 | Bref::events()->afterStartup();
36 |
37 | ColdStartTracker::coldStartFinished();
38 |
39 | $loopMax = getenv('BREF_LOOP_MAX') ?: 1;
40 | $loops = 0;
41 | while (true) {
42 | if (++$loops > $loopMax) {
43 | exit(0);
44 | }
45 | $success = $lambdaRuntime->processNextEvent($handler);
46 | // In case the execution failed, we force starting a new process regardless of BREF_LOOP_MAX
47 | // Why: an exception could have left the application in a non-clean state, this is preventive
48 | if (! $success) {
49 | exit(0);
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/Listener/BrefEventSubscriber.php:
--------------------------------------------------------------------------------
1 | beforeStartup();
29 |
30 | $lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('fpm');
31 |
32 | $appRoot = getenv('LAMBDA_TASK_ROOT');
33 | $handlerFile = $appRoot . '/' . getenv('_HANDLER');
34 | if (! is_file($handlerFile)) {
35 | $lambdaRuntime->failInitialization("Handler `$handlerFile` doesn't exist", 'Runtime.NoSuchHandler');
36 | }
37 |
38 | $phpFpm = new FpmHandler($handlerFile);
39 | try {
40 | $phpFpm->start();
41 | } catch (Throwable $e) {
42 | $lambdaRuntime->failInitialization(new RuntimeException('Error while starting PHP-FPM: ' . $e->getMessage(), 0, $e));
43 | }
44 |
45 | Bref::events()->afterStartup();
46 |
47 | ColdStartTracker::coldStartFinished();
48 |
49 | /** @phpstan-ignore-next-line */
50 | while (true) {
51 | $lambdaRuntime->processNextEvent($phpFpm);
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/LazySecretsLoader.php:
--------------------------------------------------------------------------------
1 | |string|false $envVars */
39 | $envVars = getenv(local_only: true);
40 | if (! is_array($envVars)) {
41 | return false;
42 | }
43 |
44 | // Only consider environment variables that start with "bref-ssm:"
45 | $envVarsToDecrypt = array_filter($envVars, function (string $value): bool {
46 | return str_starts_with($value, 'bref-ssm:');
47 | });
48 |
49 | return ! empty($envVarsToDecrypt);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Runtime/FileHandlerLocator.php:
--------------------------------------------------------------------------------
1 | directory = $directory ?: getcwd();
26 | }
27 |
28 | /**
29 | * {@inheritDoc}
30 | */
31 | public function get($id)
32 | {
33 | $handlerFile = $this->directory . '/' . $id;
34 | if (! is_file($handlerFile)) {
35 | throw new HandlerNotFound("Handler `$handlerFile` doesn't exist");
36 | }
37 |
38 | $handler = require $handlerFile;
39 |
40 | if (! (is_object($handler) || is_array($handler))) {
41 | throw new HandlerNotFound("Handler `$handlerFile` must return a function or object handler. See https://bref.sh/docs/runtimes/function.html");
42 | }
43 |
44 | return $handler;
45 | }
46 |
47 | /**
48 | * {@inheritDoc}
49 | */
50 | public function has($id): bool
51 | {
52 | return is_file($this->directory . '/' . $id);
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/template/http/index.php:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 | Welcome!
14 |
15 |
16 |
17 |
18 |
19 |
Hello there,
20 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/plugin/secrets.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 |
3 | /**
4 | * @param {import('./serverless').Serverless} serverless
5 | * @param {import('./serverless').Logger} log
6 | */
7 | function warnIfUsingSecretsWithoutTheBrefDependency(serverless, log) {
8 | const config = serverless.service;
9 | const allVariables = [];
10 |
11 | const check = ([name, value]) => {
12 | if (typeof value === 'string' && value.startsWith('bref-ssm:')) {
13 | allVariables.push(`${name}: ${value}`);
14 | }
15 | };
16 | Object.entries(config.provider.environment || {}).forEach(check);
17 | Object.values(config.functions || {}).forEach(f => Object.entries(f.environment || {}).forEach(check));
18 |
19 | if (allVariables.length > 0) {
20 | // Check if the bref/secrets-loader dependency is installed in composer.json
21 | if (! fs.existsSync('composer.lock')) {
22 | return;
23 | }
24 | const composerLock = JSON.parse(fs.readFileSync('composer.lock', 'utf8'));
25 | const dependencies = composerLock.packages.map(v => v.name) || {};
26 | if (dependencies.includes('bref/secrets-loader')) {
27 | return;
28 | }
29 |
30 | log.warning(`The following environment variables use the "bref-ssm:" prefix, but the "bref/secrets-loader" dependency is not installed.`);
31 | allVariables.forEach(variable => log.warning(` ${variable}`));
32 | log.warning(`The "bref/secrets-loader" dependency is required to use the "bref-ssm:" prefix. Install it by running:`);
33 | log.warning(` composer require bref/secrets-loader`);
34 | log.warning(`Learn more at https://bref.sh/docs/environment/variables.html#secrets`);
35 | log.warning();
36 | }
37 | }
38 |
39 | module.exports = {warnIfUsingSecretsWithoutTheBrefDependency};
40 |
--------------------------------------------------------------------------------
/src/Event/S3/S3Record.php:
--------------------------------------------------------------------------------
1 | record = $record;
23 | }
24 |
25 | /**
26 | * Returns the bucket that triggered the lambda.
27 | */
28 | public function getBucket(): Bucket
29 | {
30 | $bucket = $this->record['s3']['bucket'];
31 | return new Bucket($bucket['name'], $bucket['arn']);
32 | }
33 |
34 | /**
35 | * Returns the object that triggered the lambda.
36 | */
37 | public function getObject(): BucketObject
38 | {
39 | $bucket = $this->record['s3']['object'];
40 | return new BucketObject($bucket['key'], $bucket['size'] ?? 0, $bucket['versionId'] ?? null);
41 | }
42 |
43 | public function getEventTime(): DateTimeImmutable
44 | {
45 | return new DateTimeImmutable($this->record['eventTime']);
46 | }
47 |
48 | public function getEventName(): string
49 | {
50 | return $this->record['eventName'];
51 | }
52 |
53 | public function getAwsRegion(): string
54 | {
55 | return $this->record['awsRegion'];
56 | }
57 |
58 | /**
59 | * Returns the record original data as an array.
60 | *
61 | * Use this method if you want to access data that is not returned by a method in this class.
62 | */
63 | public function toArray(): array
64 | {
65 | return $this->record;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Event/Kafka/KafkaRecord.php:
--------------------------------------------------------------------------------
1 | record = $record;
20 | }
21 |
22 | public function getKey(): string
23 | {
24 | return $this->record['key'];
25 | }
26 |
27 | public function getTopic(): string
28 | {
29 | return $this->record['topic'];
30 | }
31 |
32 | public function getPartition(): int
33 | {
34 | return $this->record['partition'];
35 | }
36 |
37 | public function getOffset(): int
38 | {
39 | return $this->record['offset'];
40 | }
41 |
42 | public function getTimestamp(): int
43 | {
44 | return $this->record['timestamp'];
45 | }
46 |
47 | public function getValue(): mixed
48 | {
49 | return base64_decode($this->record['value']);
50 | }
51 |
52 | /**
53 | * A header in Kafka is an objects, with a single property. The name of the property is the name of the header. Its value is a
54 | * byte-array, representing a string. We'll normalize it to a hashmap with key and value being strings.
55 | *
56 | * @return array
57 | *
58 | * @see https://kafka.apache.org/25/javadoc/org/apache/kafka/common/header/Headers.html
59 | */
60 | public function getHeaders(): array
61 | {
62 | return array_map(
63 | function (array $chars): string {
64 | return implode('', array_map(
65 | function (int $char): string {
66 | return chr($char);
67 | },
68 | $chars,
69 | ));
70 | },
71 | array_merge(...array_values($this->record['headers']))
72 | );
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/plugin/run-console.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 |
4 | /**
5 | * @param {import('./serverless').Serverless} serverless
6 | * @param {import('./serverless').CliOptions} options
7 | */
8 | async function runConsole(serverless, options) {
9 | const region = serverless.getProvider('aws').getRegion();
10 | // Override CLI options for `sls invoke`
11 | options.function = options.function || getConsoleFunction(serverless, region);
12 | options.type = 'RequestResponse';
13 | options.data = options.args;
14 | options.log = true;
15 | // Run `sls invoke`
16 | await serverless.pluginManager.spawn('invoke');
17 | }
18 |
19 | /**
20 | * @param {import('./serverless').Serverless} serverless
21 | * @param {string} region
22 | */
23 | function getConsoleFunction(serverless, region) {
24 | const consoleLayerArn = getConsoleLayerArn(region);
25 |
26 | const functions = serverless.service.functions;
27 | const consoleFunctions = [];
28 | for (const [functionName, functionDetails] of Object.entries(functions || {})) {
29 | if (functionDetails.layers && functionDetails.layers.includes(consoleLayerArn)) {
30 | consoleFunctions.push(functionName);
31 | }
32 | }
33 | if (consoleFunctions.length === 0) {
34 | throw new serverless.classes.Error('This command invokes the Lambda "console" function, but no function was found with the "console" layer');
35 | }
36 | if (consoleFunctions.length > 1) {
37 | throw new serverless.classes.Error('More than one function contains the console layer: cannot automatically run it. Please provide a function name using the --function option.');
38 | }
39 | return consoleFunctions[0];
40 | }
41 |
42 | /**
43 | * @param {string} region
44 | * @returns {string}
45 | */
46 | function getConsoleLayerArn(region) {
47 | const json = fs.readFileSync(path.join(__dirname, '../layers.json'));
48 | const layers = JSON.parse(json.toString());
49 | const version = layers.console[region];
50 | return `arn:aws:lambda:${region}:534081306603:layer:console:${version}`;
51 | }
52 |
53 | module.exports = {runConsole};
54 |
--------------------------------------------------------------------------------
/src/Event/EventBridge/EventBridgeEvent.php:
--------------------------------------------------------------------------------
1 | event = $event;
24 | }
25 |
26 | public function getId(): string
27 | {
28 | return $this->event['id'];
29 | }
30 |
31 | public function getVersion(): string
32 | {
33 | return $this->event['version'];
34 | }
35 |
36 | public function getAwsRegion(): string
37 | {
38 | return $this->event['region'];
39 | }
40 |
41 | public function getTimestamp(): DateTimeImmutable
42 | {
43 | // Date in RFC3339 format per https://docs.aws.amazon.com/eventbridge/latest/APIReference/eventbridge-api.pdf
44 | return DateTimeImmutable::createFromFormat(DATE_RFC3339, $this->event['time']);
45 | }
46 |
47 | public function getAwsAccountId(): string
48 | {
49 | return $this->event['account'];
50 | }
51 |
52 | public function getSource(): string
53 | {
54 | return $this->event['source'];
55 | }
56 |
57 | public function getDetailType(): string
58 | {
59 | return $this->event['detail-type'];
60 | }
61 |
62 | /**
63 | * Returns the content of the EventBridge message.
64 | *
65 | * Note that when publishing an event from PHP, we JSON-encode the 'detail' field.
66 | * However, this method will not return a JSON string: it will return the decoded content.
67 | * This is how EventBridge works: we publish a message with JSON-encoded data. EventBridge decodes it
68 | * and triggers listeners with the decoded data.
69 | */
70 | public function getDetail(): mixed
71 | {
72 | return $this->event['detail'];
73 | }
74 |
75 | public function toArray(): array
76 | {
77 | return $this->event;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/Event/DynamoDb/DynamoDbRecord.php:
--------------------------------------------------------------------------------
1 | record = $record;
21 | }
22 |
23 | public function getEventName(): string
24 | {
25 | return $this->record['eventName'];
26 | }
27 |
28 | /**
29 | * Returns the key attributes of the modified item.
30 | */
31 | public function getKeys(): array
32 | {
33 | return $this->record['dynamodb']['Keys'];
34 | }
35 |
36 | /**
37 | * Returns the new version of the DynamoDB item.
38 | *
39 | * Warning: this can be null depending on the `StreamViewType`.
40 | *
41 | * @see getStreamViewType()
42 | * @see https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_StreamSpecification.html
43 | */
44 | public function getNewImage(): ?array
45 | {
46 | return $this->record['dynamodb']['NewImage'] ?? null;
47 | }
48 |
49 | /**
50 | * Returns the old version of the DynamoDB item.
51 | *
52 | * Warning: this can be null depending on the `StreamViewType`.
53 | *
54 | * @see getStreamViewType()
55 | * @see https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_StreamSpecification.html
56 | */
57 | public function getOldImage(): ?array
58 | {
59 | return $this->record['dynamodb']['OldImage'] ?? null;
60 | }
61 |
62 | public function getSequenceNumber(): string
63 | {
64 | return $this->record['dynamodb']['SequenceNumber'];
65 | }
66 |
67 | public function getSizeBytes(): int
68 | {
69 | return $this->record['dynamodb']['SizeBytes'];
70 | }
71 |
72 | /**
73 | * @see https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_StreamSpecification.html
74 | */
75 | public function getStreamViewType(): string
76 | {
77 | return $this->record['dynamodb']['StreamViewType'];
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/Event/Sqs/SqsRecord.php:
--------------------------------------------------------------------------------
1 | record = $record;
20 | }
21 |
22 | public function getMessageId(): string
23 | {
24 | return $this->record['messageId'];
25 | }
26 |
27 | /**
28 | * Returns the body of the SQS message.
29 | * The body is data that was sent to SQS by the publisher of the message.
30 | */
31 | public function getBody(): string
32 | {
33 | return $this->record['body'];
34 | }
35 |
36 | /**
37 | * Message attributes are custom attributes sent with the body, by the publisher of the message.
38 | */
39 | public function getMessageAttributes(): array
40 | {
41 | return $this->record['messageAttributes'];
42 | }
43 |
44 | /**
45 | * Returns the number of times a message has been received from the queue but not deleted.
46 | */
47 | public function getApproximateReceiveCount(): int
48 | {
49 | return (int) $this->record['attributes']['ApproximateReceiveCount'];
50 | }
51 |
52 | /**
53 | * Returns the receipt handle, the unique identifier for a specific instance of receiving a message.
54 | */
55 | public function getReceiptHandle(): string
56 | {
57 | return $this->record['receiptHandle'];
58 | }
59 |
60 | /**
61 | * Returns the name of the SQS queue that contains the message.
62 | * Queue naming constraints: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-queues.html
63 | */
64 | public function getQueueName(): string
65 | {
66 | $parts = explode(':', $this->record['eventSourceARN']);
67 |
68 | return $parts[count($parts) - 1];
69 | }
70 |
71 | /**
72 | * Returns the record original data as an array.
73 | *
74 | * Use this method if you want to access data that is not returned by a method in this class.
75 | */
76 | public function toArray(): array
77 | {
78 | return $this->record;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/Context/Context.php:
--------------------------------------------------------------------------------
1 | awsRequestId;
44 | }
45 |
46 | /**
47 | * Returns the number of milliseconds left before the execution times out.
48 | */
49 | public function getRemainingTimeInMillis(): int
50 | {
51 | return $this->deadlineMs - intval(microtime(true) * 1000);
52 | }
53 |
54 | /**
55 | * Returns the Amazon Resource Name (ARN) used to invoke the function.
56 | * Indicates if the invoker specified a version number or alias.
57 | */
58 | public function getInvokedFunctionArn(): string
59 | {
60 | return $this->invokedFunctionArn;
61 | }
62 |
63 | /**
64 | * Returns content of the AWS X-Ray trace information header
65 | *
66 | * @see https://docs.aws.amazon.com/xray/latest/devguide/xray-concepts.html#xray-concepts-tracingheader
67 | */
68 | public function getTraceId(): string
69 | {
70 | return $this->traceId;
71 | }
72 |
73 | public function jsonSerialize(): array
74 | {
75 | return [
76 | 'awsRequestId' => $this->awsRequestId,
77 | 'deadlineMs' => $this->deadlineMs,
78 | 'invokedFunctionArn' => $this->invokedFunctionArn,
79 | 'traceId' => $this->traceId,
80 | ];
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bref/bref",
3 | "license": "MIT",
4 | "type": "project",
5 | "description": "Bref is a framework to write and deploy serverless PHP applications on AWS Lambda.",
6 | "homepage": "https://bref.sh",
7 | "keywords": ["bref", "serverless", "aws", "lambda", "faas"],
8 | "autoload": {
9 | "psr-4": {
10 | "Bref\\": "src"
11 | }
12 | },
13 | "autoload-dev": {
14 | "psr-4": {
15 | "Bref\\Test\\": "tests"
16 | }
17 | },
18 | "bin": [
19 | "bref",
20 | "src/bref-local"
21 | ],
22 | "require": {
23 | "php": ">=8.0",
24 | "ext-curl": "*",
25 | "ext-json": "*",
26 | "crwlr/query-string": "^1.0.3",
27 | "hollodotme/fast-cgi-client": "^3.0.1",
28 | "nyholm/psr7": "^1.4.1",
29 | "psr/container": "^1.0|^2.0",
30 | "psr/http-message": "^1.0|^2.0",
31 | "psr/http-server-handler": "^1.0",
32 | "riverline/multipart-parser": "^2.1.2",
33 | "symfony/process": "^4.4|^5.0|^6.0|^7.0|^8.0"
34 | },
35 | "require-dev": {
36 | "async-aws/core": "^1.0",
37 | "async-aws/lambda": "^1.0",
38 | "aws/aws-sdk-php": "^3.172",
39 | "bref/secrets-loader": "^1.0",
40 | "dms/phpunit-arraysubset-asserts": "^0.4",
41 | "doctrine/coding-standard": "^8.0",
42 | "guzzlehttp/guzzle": "^7.5",
43 | "phpstan/phpstan": "^1.10.26",
44 | "phpunit/phpunit": "^9.6.10",
45 | "symfony/console": "^4.4|^5.0|^6.0|^7.0|^8.0",
46 | "symfony/yaml": "^4.4|^5.0|^6.0|^7.0|^8.0"
47 | },
48 | "scripts": {
49 | "test": [
50 | "@static-analysis",
51 | "@unit-tests",
52 | "@code-style"
53 | ],
54 | "code-style": [
55 | "vendor/bin/phpcs"
56 | ],
57 | "static-analysis": [
58 | "vendor/bin/phpstan analyse"
59 | ],
60 | "unit-tests": [
61 | "vendor/bin/phpunit --testsuite small"
62 | ]
63 | },
64 | "scripts-descriptions": {
65 | "test": "Run the test suite",
66 | "code-style": "Run code style checks using PHP_CodeSniffer",
67 | "static-analysis": "Run static analysis using PHPStan",
68 | "tests": "Run unit tests with PHPUnit"
69 | },
70 | "config": {
71 | "allow-plugins": {
72 | "dealerdirect/phpcodesniffer-composer-installer": true
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/Listener/EventDispatcher.php:
--------------------------------------------------------------------------------
1 | subscribers[] = $subscriber;
28 | }
29 |
30 | /**
31 | * Trigger the `beforeStartup` event.
32 | *
33 | * @internal This method is called by Bref and should not be called by user code.
34 | */
35 | public function beforeStartup(): void
36 | {
37 | foreach ($this->subscribers as $listener) {
38 | $listener->beforeStartup();
39 | }
40 | }
41 |
42 | /**
43 | * Trigger the `afterStartup` event.
44 | *
45 | * @internal This method is called by Bref and should not be called by user code.
46 | */
47 | public function afterStartup(): void
48 | {
49 | foreach ($this->subscribers as $listener) {
50 | $listener->afterStartup();
51 | }
52 | }
53 |
54 | /**
55 | * Trigger the `beforeInvoke` event.
56 | *
57 | * @internal This method is called by Bref and should not be called by user code.
58 | */
59 | public function beforeInvoke(
60 | callable | Handler | RequestHandlerInterface $handler,
61 | mixed $event,
62 | Context $context,
63 | ): void {
64 | foreach ($this->subscribers as $listener) {
65 | $listener->beforeInvoke($handler, $event, $context);
66 | }
67 | }
68 |
69 | /**
70 | * Trigger the `afterInvoke` event.
71 | *
72 | * @internal This method is called by Bref and should not be called by user code.
73 | */
74 | public function afterInvoke(
75 | callable | Handler | RequestHandlerInterface $handler,
76 | mixed $event,
77 | Context $context,
78 | mixed $result,
79 | \Throwable | null $error = null,
80 | ): void {
81 | foreach ($this->subscribers as $listener) {
82 | $listener->afterInvoke($handler, $event, $context, $result, $error);
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/bref-local:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | get($handler);
34 | } catch (NotFoundExceptionInterface $e) {
35 | throw new Exception($e->getMessage() . PHP_EOL . 'Reminder: `bref-local` can invoke event-driven functions that use the FUNCTION runtime, not the web app (or "FPM") runtime. Check out https://bref.sh/docs/web-apps/local-development.html to run web applications locally.');
36 | }
37 |
38 | try {
39 | $event = $data ? json_decode($data, true, 512, JSON_THROW_ON_ERROR) : null;
40 | } catch (JsonException $e) {
41 | throw new Exception('The JSON provided for the event data is invalid JSON.');
42 | }
43 |
44 | // Same configuration as the Bref runtime on Lambda
45 | ini_set('display_errors', '1');
46 | error_reporting(E_ALL);
47 |
48 | $startTime = logStart();
49 |
50 | try {
51 | $invoker = new Invoker;
52 | $result = $invoker->invoke($handler, $event, Context::fake());
53 | } catch (Throwable $e) {
54 | echo get_class($e) . ': ' . $e->getMessage() . PHP_EOL;
55 | echo 'Stack trace:' . PHP_EOL;
56 | echo $e->getTraceAsString() . PHP_EOL;
57 | exit(1);
58 | }
59 |
60 | logEnd($startTime);
61 | // Show the invocation result
62 | echo json_encode($result, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . PHP_EOL;
63 |
64 | function logStart(): float
65 | {
66 | echo "START\n";
67 | return microtime(true);
68 | }
69 |
70 | function logEnd(float $startTime): void
71 | {
72 | $duration = ceil((microtime(true) - $startTime) * 1000);
73 | $memoryUsed = ceil(memory_get_usage() / 1024 / 1024);
74 | echo "END Duration: $duration ms Max Memory Used: $memoryUsed MB\n\n";
75 | }
76 |
--------------------------------------------------------------------------------
/src/Event/Sns/SnsRecord.php:
--------------------------------------------------------------------------------
1 | record = $record;
25 | }
26 |
27 | public function getEventSubscriptionArn(): string
28 | {
29 | return $this->record['EventSubscriptionArn'];
30 | }
31 |
32 | public function getMessageId(): string
33 | {
34 | return $this->record['Sns']['MessageId'];
35 | }
36 |
37 | /**
38 | * Optional parameter to be used as the "Subject" line when the message is delivered to email endpoints. This field will also be included, if present, in the standard JSON messages delivered to other endpoints.
39 | */
40 | public function getSubject(): string
41 | {
42 | return $this->record['Sns']['Subject'];
43 | }
44 |
45 | /**
46 | * Returns the body of the SNS message.
47 | * The body is data that was sent to SNS by the publisher of the message.
48 | */
49 | public function getMessage(): string
50 | {
51 | return $this->record['Sns']['Message'];
52 | }
53 |
54 | /**
55 | * Message attributes are custom attributes sent with the message, by the publisher of the message.
56 | *
57 | * @return array
58 | */
59 | public function getMessageAttributes(): array
60 | {
61 | return array_map(function (array $attribute): MessageAttribute {
62 | return new MessageAttribute($attribute);
63 | }, $this->record['Sns']['MessageAttributes']);
64 | }
65 |
66 | public function getTopicArn(): string
67 | {
68 | return $this->record['Sns']['TopicArn'];
69 | }
70 |
71 | public function getTimestamp(): DateTimeImmutable
72 | {
73 | return DateTimeImmutable::createFromFormat(DATE_RFC3339_EXTENDED, $this->record['Sns']['Timestamp']);
74 | }
75 |
76 | /**
77 | * Returns the record original data as an array.
78 | *
79 | * Use this method if you want to access data that is not returned by a method in this class.
80 | */
81 | public function toArray(): array
82 | {
83 | return $this->record;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/ConsoleRuntime/Main.php:
--------------------------------------------------------------------------------
1 | beforeStartup();
25 |
26 | $lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('console');
27 |
28 | $appRoot = getenv('LAMBDA_TASK_ROOT');
29 | $handlerFile = $appRoot . '/' . getenv('_HANDLER');
30 | if (! is_file($handlerFile)) {
31 | $lambdaRuntime->failInitialization("Handler `$handlerFile` doesn't exist", 'Runtime.NoSuchHandler');
32 | }
33 |
34 | Bref::events()->afterStartup();
35 |
36 | ColdStartTracker::coldStartFinished();
37 |
38 | /** @phpstan-ignore-next-line */
39 | while (true) {
40 | $lambdaRuntime->processNextEvent(function ($event, Context $context) use ($handlerFile): array {
41 | if (is_array($event)) {
42 | // Backward compatibility with the former CLI invocation format
43 | $cliOptions = $event['cli'] ?? '';
44 | } elseif (is_string($event)) {
45 | $cliOptions = $event;
46 | } else {
47 | $cliOptions = '';
48 | }
49 |
50 | $timeout = max(1, $context->getRemainingTimeInMillis() / 1000 - 1);
51 | $command = sprintf('php %s %s 2>&1', $handlerFile, $cliOptions);
52 | $process = Process::fromShellCommandline($command, null, null, null, $timeout);
53 |
54 | $process->run(function ($type, $buffer): void {
55 | echo $buffer;
56 | });
57 |
58 | $exitCode = $process->getExitCode();
59 |
60 | $output = $process->getOutput();
61 | // Trim the output to stay under the 6MB limit for AWS Lambda
62 | // We only keep 5MB because at this point the difference won't be important
63 | // and we'll serialize the output to JSON which will add some overhead
64 | $output = substr($output, max(0, strlen($output) - 5 * 1024 * 1024));
65 |
66 | if ($exitCode > 0) {
67 | // This needs to be thrown so that AWS Lambda knows the invocation failed
68 | // (e.g. important for error rates in CloudWatch)
69 | throw new CommandFailed($output);
70 | }
71 |
72 | return [
73 | 'exitCode' => $exitCode, // will always be 0
74 | 'output' => $output,
75 | ];
76 | });
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/Event/ApiGateway/WebsocketEvent.php:
--------------------------------------------------------------------------------
1 | domainName = $event['requestContext']['domainName'];
51 | $this->connectionId = $event['requestContext']['connectionId'];
52 | $this->routeKey = $event['requestContext']['routeKey'];
53 | $this->apiId = $event['requestContext']['apiId'];
54 | $this->stage = $event['requestContext']['stage'];
55 | $this->event = $event;
56 |
57 | if (isset($event['requestContext']['eventType'])) {
58 | $this->eventType = $event['requestContext']['eventType'];
59 | }
60 |
61 | if (isset($event['body'])) {
62 | $this->body = $event['body'];
63 | }
64 | }
65 |
66 | public function toArray(): array
67 | {
68 | return $this->event;
69 | }
70 |
71 | public function getRouteKey(): string
72 | {
73 | return $this->routeKey;
74 | }
75 |
76 | public function getEventType(): ?string
77 | {
78 | return $this->eventType;
79 | }
80 |
81 | public function getBody(): mixed
82 | {
83 | return $this->body;
84 | }
85 |
86 | public function getConnectionId(): string
87 | {
88 | return $this->connectionId;
89 | }
90 |
91 | public function getDomainName(): string
92 | {
93 | return $this->domainName;
94 | }
95 |
96 | public function getApiId(): string
97 | {
98 | return $this->apiId;
99 | }
100 |
101 | public function getStage(): string
102 | {
103 | return $this->stage;
104 | }
105 |
106 | public function getRegion(): string
107 | {
108 | return getenv('AWS_REGION');
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/Cli/init.php:
--------------------------------------------------------------------------------
1 | find('serverless')) {
10 | warning(
11 | 'The `serverless` command is not installed.' . PHP_EOL .
12 | 'You will not be able to deploy your application unless it is installed' . PHP_EOL .
13 | 'Please follow the instructions at https://bref.sh/docs/installation.html' . PHP_EOL .
14 | 'If you have the `serverless` command available elsewhere (eg in a Docker container) you can ignore this warning.' . PHP_EOL
15 | );
16 | }
17 |
18 | if (! $template) {
19 | $intro = green('What kind of application are you building?');
20 | echo << ') ?: '0';
28 | echo PHP_EOL;
29 | if (! in_array($choice, ['0', '1', '2'], true)) {
30 | error('Invalid response (must be "0", "1" or "2"), aborting');
31 | }
32 |
33 | $template = [
34 | '0' => 'http',
35 | '1' => 'function',
36 | '2' => 'symfony',
37 | ][$choice];
38 | }
39 |
40 | $rootPath = dirname(__DIR__, 2) . "/template/$template";
41 |
42 | if (file_exists($rootPath . '/index.php')) {
43 | createFile($rootPath, 'index.php');
44 | }
45 | createFile($rootPath, 'serverless.yml');
46 |
47 | // If these is a `.gitignore` file in the current directory, let's add `.serverless` to it
48 | if (file_exists('.gitignore')) {
49 | $gitignore = file_get_contents('.gitignore');
50 | if (! str_contains($gitignore, '.serverless')) {
51 | file_put_contents('.gitignore', PHP_EOL . '.serverless' . PHP_EOL, FILE_APPEND);
52 | success('Added `.serverless` to your `.gitignore` file.');
53 | }
54 | }
55 |
56 | success('Project initialized and ready to test or deploy.');
57 | }
58 |
59 | /**
60 | * Creates files from the template directory and automatically adds them to git
61 | */
62 | function createFile(string $templatePath, string $file): void
63 | {
64 | echo "Creating $file\n";
65 |
66 | if (file_exists($file)) {
67 | $overwrite = false;
68 | echo "A file named $file already exists, do you want to overwrite it? [y/N]\n";
69 | $choice = strtolower(readline('> ') ?: 'n');
70 | echo PHP_EOL;
71 | if ($choice === 'y') {
72 | $overwrite = true;
73 | } elseif (! in_array($choice, ['y', 'n'], true)) {
74 | error('Invalid response (must be "y" or "n"), aborting');
75 | }
76 | if (! $overwrite) {
77 | echo "Skipping $file\n";
78 | return;
79 | }
80 | }
81 |
82 | $template = file_get_contents("$templatePath/$file");
83 | if (! $template) {
84 | error("Could not read file $templatePath/$file");
85 | }
86 | $template = str_replace('PHP_VERSION', PHP_MAJOR_VERSION . PHP_MINOR_VERSION, $template);
87 | file_put_contents($file, $template);
88 |
89 | /*
90 | * We check if this is a git repository to automatically add file to git.
91 | */
92 | $message = "$file successfully created";
93 | if ((new Process(['git', 'rev-parse', '--is-inside-work-tree']))->run() === 0) {
94 | (new Process(['git', 'add', $file]))->run();
95 | $message .= ' and added to git automatically';
96 | }
97 |
98 | echo PHP_EOL;
99 | success("$message.");
100 | }
101 |
--------------------------------------------------------------------------------
/src/Bref.php:
--------------------------------------------------------------------------------
1 | subscribe() instead */
16 | private static array $hooks = [
17 | 'beforeStartup' => [],
18 | 'beforeInvoke' => [],
19 | ];
20 | private static EventDispatcher $eventDispatcher;
21 |
22 | /**
23 | * Configure the container that provides Lambda handlers.
24 | *
25 | * @param Closure(): ContainerInterface $containerProvider Function that must return a `ContainerInterface`.
26 | */
27 | public static function setContainer(Closure $containerProvider): void
28 | {
29 | self::$containerProvider = $containerProvider;
30 | }
31 |
32 | public static function events(): EventDispatcher
33 | {
34 | if (! isset(self::$eventDispatcher)) {
35 | self::$eventDispatcher = new EventDispatcher;
36 | }
37 | return self::$eventDispatcher;
38 | }
39 |
40 | /**
41 | * Register a hook to be executed before the runtime starts.
42 | *
43 | * Warning: hooks are low-level extension points to be used by framework
44 | * integrations. For user code, it is not recommended to use them. Use your
45 | * framework's extension points instead.
46 | *
47 | * @deprecated Use Bref::events()->subscribe() instead.
48 | */
49 | public static function beforeStartup(Closure $hook): void
50 | {
51 | self::$hooks['beforeStartup'][] = $hook;
52 | }
53 |
54 | /**
55 | * Register a hook to be executed before any Lambda invocation.
56 | *
57 | * Warning: hooks are low-level extension points to be used by framework
58 | * integrations. For user code, it is not recommended to use them. Use your
59 | * framework's extension points instead.
60 | *
61 | * @deprecated Use Bref::events()->subscribe() instead.
62 | */
63 | public static function beforeInvoke(Closure $hook): void
64 | {
65 | self::$hooks['beforeInvoke'][] = $hook;
66 | }
67 |
68 | /**
69 | * @param 'beforeStartup'|'beforeInvoke' $hookName
70 | *
71 | * @internal Used by the Bref runtime
72 | */
73 | public static function triggerHooks(string $hookName): void
74 | {
75 | foreach (self::$hooks[$hookName] as $hook) {
76 | $hook();
77 | }
78 | }
79 |
80 | /**
81 | * @internal Used by the Bref runtime
82 | */
83 | public static function getContainer(): ContainerInterface
84 | {
85 | if (! self::$container) {
86 | if (self::$containerProvider) {
87 | self::$container = (self::$containerProvider)();
88 | if (! self::$container instanceof ContainerInterface) {
89 | throw new RuntimeException('The closure provided to Bref\Bref::setContainer() did not return an instance of ' . ContainerInterface::class);
90 | }
91 | } else {
92 | self::$container = new FileHandlerLocator;
93 | }
94 | }
95 |
96 | return self::$container;
97 | }
98 |
99 | /**
100 | * @internal For tests.
101 | */
102 | public static function reset(): void
103 | {
104 | self::$containerProvider = null;
105 | self::$container = null;
106 | self::$hooks = [
107 | 'beforeStartup' => [],
108 | 'beforeInvoke' => [],
109 | ];
110 | self::$eventDispatcher = new EventDispatcher;
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/Runtime/ColdStartTracker.php:
--------------------------------------------------------------------------------
1 | 0.1;
63 | } else {
64 | // There was no cold start, we are in a warm start
65 | self::$wasProactiveInitialization = false;
66 | }
67 | }
68 |
69 | /**
70 | * Timestamp of the beginning of the cold start.
71 | */
72 | public static function getColdStartBeginningTime(): float
73 | {
74 | return self::$coldStartBeginningTime;
75 | }
76 |
77 | /**
78 | * Timestamp of the end of the cold start.
79 | */
80 | public static function getColdStartEndedTime(): float
81 | {
82 | return self::$coldStartEndedTime;
83 | }
84 |
85 | /**
86 | * Returns `true` if the current Lambda invocation contained a cold start.
87 | *
88 | * This is `true` even if the cold start was a proactive initialization.
89 | *
90 | * This is no longer `true` once the second invocation (and subsequent invocations) start.
91 | */
92 | public static function currentInvocationIsColdStart(): bool
93 | {
94 | return self::$currentInvocationIsColdStart;
95 | }
96 |
97 | /**
98 | * Returns `true` if the current Lambda invocation contains a cold start that was "user-facing".
99 | *
100 | * "User-facing" means that the cold start duration was part of the invocation duration that the
101 | * invoker of the Lambda function experienced.
102 | *
103 | * For example, if the application is a web application, a "user-facing" cold start of 1 second
104 | * means that the response time of the first request contained a 1 second delay.
105 | */
106 | public static function currentInvocationIsUserFacingColdStart(): bool
107 | {
108 | return self::currentInvocationIsColdStart() && ! self::wasProactiveInitialization();
109 | }
110 |
111 | /**
112 | * Returns `true` if this Lambda sandbox was initialized proactively.
113 | */
114 | public static function wasProactiveInitialization(): bool
115 | {
116 | return self::$wasProactiveInitialization;
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/bref:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | 'vendor/bin/bref',
119 | 'v' => 2, // Bref version
120 | 'c' => $argv[1] ?? '',
121 | 'ci' => $ci,
122 | // anonymous user ID created by the Serverless Framework
123 | 'uid' => $userConfig['frameworkId'] ?? '',
124 | ], JSON_THROW_ON_ERROR);
125 |
126 | $sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
127 | // This IP address is the Bref server.
128 | // If this server is down or unreachable, there should be no difference in overhead
129 | // or execution time.
130 | socket_sendto($sock, $message, strlen($message), 0, '108.128.197.71', 8888);
131 | socket_close($sock);
132 | }
133 |
--------------------------------------------------------------------------------
/src/Event/Http/HttpResponse.php:
--------------------------------------------------------------------------------
1 | $headers
16 | */
17 | public function __construct(string $body, array $headers = [], int $statusCode = 200)
18 | {
19 | $this->body = $body;
20 | $this->headers = $headers;
21 | $this->statusCode = $statusCode;
22 | }
23 |
24 | public function toApiGatewayFormat(bool $multiHeaders = false, ?string $awsRequestId = null): array
25 | {
26 | $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES');
27 |
28 | $headers = [];
29 | foreach ($this->headers as $name => $values) {
30 | $name = $this->capitalizeHeaderName($name);
31 |
32 | if ($multiHeaders) {
33 | // Make sure the values are always arrays
34 | $headers[$name] = is_array($values) ? $values : [$values];
35 | } else {
36 | // Make sure the values are never arrays
37 | $headers[$name] = is_array($values) ? end($values) : $values;
38 | }
39 | }
40 |
41 | $this->checkHeadersSize($headers, $awsRequestId);
42 |
43 | // The headers must be a JSON object. If the PHP array is empty it is
44 | // serialized to `[]` (we want `{}`) so we force it to an empty object.
45 | $headers = empty($headers) ? new \stdClass : $headers;
46 |
47 | // Support for multi-value headers (only in version 1.0 of the http payload)
48 | $headersKey = $multiHeaders ? 'multiValueHeaders' : 'headers';
49 |
50 | // This is the format required by the AWS_PROXY lambda integration
51 | // See https://stackoverflow.com/questions/43708017/aws-lambda-api-gateway-error-malformed-lambda-proxy-response
52 | return [
53 | 'isBase64Encoded' => $base64Encoding,
54 | 'statusCode' => $this->statusCode,
55 | $headersKey => $headers,
56 | 'body' => $base64Encoding ? base64_encode($this->body) : $this->body,
57 | ];
58 | }
59 |
60 | /**
61 | * See https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response
62 | */
63 | public function toApiGatewayFormatV2(?string $awsRequestId = null): array
64 | {
65 | $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES');
66 |
67 | $headers = [];
68 | $cookies = [];
69 | foreach ($this->headers as $name => $values) {
70 | $name = $this->capitalizeHeaderName($name);
71 |
72 | if ($name === 'Set-Cookie') {
73 | $cookies = is_array($values) ? $values : [$values];
74 | } else {
75 | // Make sure the values are never arrays
76 | // because API Gateway v2 does not support multi-value headers
77 | $headers[$name] = is_array($values) ? implode(', ', $values) : $values;
78 | }
79 | }
80 |
81 | $this->checkHeadersSize(array_merge(
82 | $headers,
83 | ['Set-Cookie' => $cookies], // include cookies in the size check
84 | ), $awsRequestId);
85 |
86 | // The headers must be a JSON object. If the PHP array is empty it is
87 | // serialized to `[]` (we want `{}`) so we force it to an empty object.
88 | $headers = empty($headers) ? new \stdClass : $headers;
89 |
90 | return [
91 | 'cookies' => $cookies,
92 | 'isBase64Encoded' => $base64Encoding,
93 | 'statusCode' => $this->statusCode,
94 | 'headers' => $headers,
95 | 'body' => $base64Encoding ? base64_encode($this->body) : $this->body,
96 | ];
97 | }
98 |
99 | /**
100 | * See https://github.com/zendframework/zend-diactoros/blob/754a2ceb7ab753aafe6e3a70a1fb0370bde8995c/src/Response/SapiEmitterTrait.php#L96
101 | */
102 | private function capitalizeHeaderName(string $name): string
103 | {
104 | $name = str_replace('-', ' ', $name);
105 | $name = ucwords($name);
106 | return str_replace(' ', '-', $name);
107 | }
108 |
109 | /**
110 | * API Gateway v1 and v2 have a headers total max size of 10 KB.
111 | * ALB has a max size of 32 KB.
112 | * It's hard to calculate the exact size of headers here, so we just
113 | * estimate it roughly: if above 9.5 KB we log a warning.
114 | *
115 | * @param array $headers
116 | */
117 | private function checkHeadersSize(array $headers, ?string $awsRequestId): void
118 | {
119 | $estimatedHeadersSize = 0;
120 | foreach ($headers as $name => $values) {
121 | $estimatedHeadersSize += strlen($name);
122 | if (is_array($values)) {
123 | foreach ($values as $value) {
124 | $estimatedHeadersSize += strlen($value);
125 | }
126 | } else {
127 | $estimatedHeadersSize += strlen($values);
128 | }
129 | }
130 |
131 | if ($estimatedHeadersSize > 9_500) {
132 | echo "$awsRequestId\tWARNING\tThe total size of HTTP response headers is estimated to be above 10 KB, which is the API Gateway limit. If the limit is reached, the HTTP response will be a 500 error.\n";
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/Event/Http/Psr7Bridge.php:
--------------------------------------------------------------------------------
1 | getHeaders();
29 |
30 | [$files, $parsedBody] = self::parseBodyAndUploadedFiles($event);
31 | [$user, $password] = $event->getBasicAuthCredentials();
32 |
33 | $server = array_filter([
34 | 'CONTENT_LENGTH' => $headers['content-length'][0] ?? null,
35 | 'CONTENT_TYPE' => $event->getContentType(),
36 | 'DOCUMENT_ROOT' => getcwd(),
37 | 'QUERY_STRING' => $event->getQueryString(),
38 | 'REQUEST_METHOD' => $event->getMethod(),
39 | 'SERVER_NAME' => $event->getServerName(),
40 | 'SERVER_PORT' => $event->getServerPort(),
41 | 'SERVER_PROTOCOL' => $event->getProtocol(),
42 | 'PATH_INFO' => $event->getPath(),
43 | 'HTTP_HOST' => $headers['host'] ?? null,
44 | 'REMOTE_ADDR' => $event->getSourceIp(),
45 | 'REMOTE_PORT' => $event->getRemotePort(),
46 | 'REQUEST_TIME' => time(),
47 | 'REQUEST_TIME_FLOAT' => microtime(true),
48 | 'REQUEST_URI' => $event->getUri(),
49 | 'PHP_AUTH_USER' => $user,
50 | 'PHP_AUTH_PW' => $password,
51 | ]);
52 |
53 | foreach ($headers as $name => $values) {
54 | $server['HTTP_' . strtoupper(str_replace('-', '_', (string) $name))] = $values[0];
55 | }
56 |
57 | /**
58 | * Nyholm/psr7 does not rewind body streams, we do it manually
59 | * so that users can fetch the content of the body directly.
60 | */
61 | $bodyStream = Stream::create($event->getBody());
62 | $bodyStream->rewind();
63 |
64 | $request = new ServerRequest(
65 | $event->getMethod(),
66 | $event->getUri(),
67 | $event->getHeaders(),
68 | $bodyStream,
69 | $event->getProtocolVersion(),
70 | $server
71 | );
72 |
73 | foreach ($event->getPathParameters() as $key => $value) {
74 | $request = $request->withAttribute($key, $value);
75 | }
76 |
77 | return $request->withUploadedFiles($files)
78 | ->withCookieParams($event->getCookies())
79 | ->withQueryParams($event->getQueryParameters())
80 | ->withParsedBody($parsedBody)
81 | ->withAttribute('lambda-event', $event)
82 | ->withAttribute('lambda-context', $context);
83 | }
84 |
85 | /**
86 | * Create a ALB/API Gateway response from a PSR-7 response.
87 | */
88 | public static function convertResponse(ResponseInterface $response): HttpResponse
89 | {
90 | $response->getBody()->rewind();
91 | $body = $response->getBody()->getContents();
92 |
93 | return new HttpResponse($body, $response->getHeaders(), $response->getStatusCode());
94 | }
95 |
96 | /**
97 | * @return array{0: array, 1: array|null}
98 | */
99 | private static function parseBodyAndUploadedFiles(HttpRequestEvent $event): array
100 | {
101 | $contentType = $event->getContentType();
102 | if ($contentType === null || $event->getMethod() !== 'POST') {
103 | return [[], null];
104 | }
105 |
106 | if (str_starts_with($contentType, 'application/x-www-form-urlencoded')) {
107 | $parsedBody = [];
108 | parse_str($event->getBody(), $parsedBody);
109 | return [[], $parsedBody];
110 | }
111 |
112 | // Parse the body as multipart/form-data
113 | $document = new Part("Content-type: $contentType\r\n\r\n" . $event->getBody());
114 | if (! $document->isMultiPart()) {
115 | return [[], null];
116 | }
117 | $parsedBody = null;
118 | $files = [];
119 | foreach ($document->getParts() as $part) {
120 | if ($part->isFile()) {
121 | $tmpPath = tempnam(sys_get_temp_dir(), self::UPLOADED_FILES_PREFIX);
122 | if ($tmpPath === false) {
123 | throw new RuntimeException('Unable to create a temporary directory');
124 | }
125 | file_put_contents($tmpPath, $part->getBody());
126 | $file = new UploadedFile($tmpPath, filesize($tmpPath), UPLOAD_ERR_OK, $part->getFileName(), $part->getMimeType());
127 | self::parseKeyAndInsertValueInArray($files, $part->getName(), $file);
128 | } else {
129 | if ($parsedBody === null) {
130 | $parsedBody = [];
131 | }
132 | self::parseKeyAndInsertValueInArray($parsedBody, $part->getName(), $part->getBody());
133 | }
134 | }
135 | return [$files, $parsedBody];
136 | }
137 |
138 | /**
139 | * Parse a string key like "files[id_cards][jpg][]" and do $array['files']['id_cards']['jpg'][] = $value
140 | */
141 | private static function parseKeyAndInsertValueInArray(array &$array, string $key, mixed $value): void
142 | {
143 | $parsed = [];
144 | // We use parse_str to parse the key in the same way PHP does natively
145 | // We use "=mock" because the value can be an object (in case of uploaded files)
146 | parse_str(urlencode($key) . '=mock', $parsed);
147 | // Replace `mock` with the actual value
148 | array_walk_recursive($parsed, fn (&$v) => $v = $value);
149 | // Merge recursively into the main array to avoid overwriting existing values
150 | $array = array_merge_recursive($array, $parsed);
151 | }
152 |
153 | /**
154 | * Cleanup previously uploaded files.
155 | */
156 | public static function cleanupUploadedFiles(): void
157 | {
158 | // See https://github.com/brefphp/bref/commit/c77d9f5abf021f29fa96b5720b7b84adbd199092#r137983026
159 | $tmpFiles = glob(sys_get_temp_dir() . '/' . self::UPLOADED_FILES_PREFIX . '[A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9]');
160 |
161 | if ($tmpFiles !== false) {
162 | foreach ($tmpFiles as $file) {
163 | if (is_file($file)) {
164 | // Silence warnings, we don't want to crash the whole runtime
165 | @unlink($file);
166 | }
167 | }
168 | }
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/src/FpmRuntime/FpmHandler.php:
--------------------------------------------------------------------------------
1 | start();
29 | * $lambdaResponse = $phpFpm->handle($event);
30 | * $phpFpm->stop();
31 | * [send the $lambdaResponse];
32 | *
33 | * @internal
34 | */
35 | final class FpmHandler extends HttpHandler
36 | {
37 | private const SOCKET = '/tmp/.bref/php-fpm.sock';
38 | private const PID_FILE = '/tmp/.bref/php-fpm.pid';
39 | private const CONFIG = '/opt/bref/etc/php-fpm.conf';
40 | /**
41 | * We define this constant instead of using the PHP one because that avoids
42 | * depending on the pcntl extension.
43 | */
44 | private const SIGTERM = 15;
45 |
46 | private ?Client $client;
47 | private UnixDomainSocket $connection;
48 | private string $handler;
49 | private string $configFile;
50 | /** @var resource|null */
51 | private $fpm;
52 |
53 | public function __construct(string $handler, string $configFile = self::CONFIG)
54 | {
55 | $this->handler = $handler;
56 | $this->configFile = $configFile;
57 | }
58 |
59 | /**
60 | * Start the PHP-FPM process.
61 | *
62 | * @throws Exception
63 | */
64 | public function start(): void
65 | {
66 | // In case Lambda stopped our process (e.g. because of a timeout) we need to make sure PHP-FPM has stopped
67 | // as well and restart it
68 | if ($this->isReady()) {
69 | $this->killExistingFpm();
70 | }
71 |
72 | if (! is_dir(dirname(self::SOCKET))) {
73 | mkdir(dirname(self::SOCKET));
74 | }
75 |
76 | /**
77 | * --nodaemonize: we want to keep control of the process
78 | * --force-stderr: force logs to be sent to stderr, which will allow us to send them to CloudWatch
79 | */
80 | $resource = @proc_open(['php-fpm', '--nodaemonize', '--force-stderr', '--fpm-config', $this->configFile], [], $pipes);
81 |
82 | if (! is_resource($resource)) {
83 | throw new RuntimeException('PHP-FPM failed to start');
84 | }
85 | $this->fpm = $resource;
86 |
87 | $this->client = new Client;
88 | $this->connection = new UnixDomainSocket(self::SOCKET, 1000, 900000);
89 |
90 | $this->waitUntilReady();
91 | }
92 |
93 | /**
94 | * @throws Exception
95 | */
96 | public function stop(): void
97 | {
98 | if ($this->isFpmRunning()) {
99 | // Give it less than a second to stop (500ms should be plenty enough time)
100 | // this is for the case where the script timed out: we reserve 1 second before the end
101 | // of the Lambda timeout, so we must kill everything and restart FPM in 1 second.
102 | $this->stopFpm(0.5);
103 | if ($this->isReady()) {
104 | throw new Exception('PHP-FPM cannot be stopped');
105 | }
106 | }
107 | }
108 |
109 | /**
110 | * @throws Exception
111 | */
112 | public function __destruct()
113 | {
114 | $this->stop();
115 | }
116 |
117 | /**
118 | * Proxy the API Gateway event to PHP-FPM and return its response.
119 | *
120 | * @throws FastCgiCommunicationFailed
121 | * @throws Timeout
122 | * @throws Exception
123 | */
124 | public function handleRequest(HttpRequestEvent $event, Context $context): HttpResponse
125 | {
126 | $request = $this->eventToFastCgiRequest($event, $context);
127 |
128 | // The script will timeout 1 second before the remaining time
129 | // to allow some time for Bref/PHP-FPM to recover and cleanup
130 | $margin = 1000;
131 | $timeoutDelayInMs = max(1000, $context->getRemainingTimeInMillis() - $margin);
132 |
133 | try {
134 | $socketId = $this->client->sendAsyncRequest($this->connection, $request);
135 |
136 | $response = $this->client->readResponse($socketId, $timeoutDelayInMs);
137 | } catch (TimedoutException) {
138 | $invocationId = $context->getAwsRequestId();
139 | echo "$invocationId The PHP script timed out. Bref will now restart PHP-FPM to start from a clean slate and flush the PHP logs.\nTimeouts can happen for example when trying to connect to a remote API or database, if this happens continuously check for those.\nIf you are using a RDS database, read this: https://bref.sh/docs/environment/database.html#accessing-the-internet\n";
140 |
141 | /**
142 | * Restart FPM so that the blocked script is 100% terminated and that its logs are flushed to stderr.
143 | *
144 | * - "why restart FPM?": if we don't, the previous request continues to execute on the next request
145 | * - "why not send a SIGUSR2 signal to FPM?": that was a promising approach because SIGUSR2
146 | * causes FPM to cleanly stop the FPM worker that is stuck in a timeout/waiting state.
147 | * It also causes all worker logs buffered by FPM to be written to stderr (great!).
148 | * This takes a bit of time (a few ms), but it's faster than rebooting FPM entirely.
149 | * However, the downside is that it doesn't "kill" the previous request execution:
150 | * it merely stops the execution of the line of code that is waiting (e.g. "sleep()",
151 | * "file_get_contents()", ...) and continues to the next line. That's super weird!
152 | * So SIGUSR2 isn't a great solution in the end.
153 | */
154 | $this->stop();
155 | $this->start();
156 |
157 | // Throw an exception so that:
158 | // - this is reported as a Lambda execution error ("error rate" metrics are accurate)
159 | // - the CloudWatch logs correctly reflect that an execution error occurred
160 | // - the 500 response is the same as if an exception happened in Bref
161 | throw new Timeout($timeoutDelayInMs, $context->getAwsRequestId());
162 | } catch (Throwable $e) {
163 | printf(
164 | "Error communicating with PHP-FPM to read the HTTP response. Bref will restart PHP-FPM now. Original exception message: %s %s\n",
165 | get_class($e),
166 | $e->getMessage()
167 | );
168 |
169 | // Restart PHP-FPM: in some cases PHP-FPM is borked, that's the only way we can recover
170 | $this->stop();
171 | $this->start();
172 |
173 | throw new FastCgiCommunicationFailed;
174 | }
175 |
176 | $responseHeaders = $this->getResponseHeaders($response);
177 |
178 | // Extract the status code
179 | if (isset($responseHeaders['status'])) {
180 | $status = (int) (is_array($responseHeaders['status']) ? $responseHeaders['status'][0] : $responseHeaders['status']);
181 | unset($responseHeaders['status']);
182 | }
183 |
184 | $this->ensureStillRunning();
185 |
186 | return new HttpResponse($response->getBody(), $responseHeaders, $status ?? 200);
187 | }
188 |
189 | /**
190 | * @throws Exception If the PHP-FPM process is not running anymore.
191 | */
192 | private function ensureStillRunning(): void
193 | {
194 | if (! $this->isFpmRunning()) {
195 | throw new Exception('PHP-FPM has stopped for an unknown reason');
196 | }
197 | }
198 |
199 | /**
200 | * @throws Exception
201 | */
202 | private function waitUntilReady(): void
203 | {
204 | $wait = 5000; // 5ms
205 | $timeout = 5000000; // 5 secs
206 | $elapsed = 0;
207 |
208 | while (! $this->isReady()) {
209 | usleep($wait);
210 | $elapsed += $wait;
211 |
212 | if ($elapsed > $timeout) {
213 | throw new Exception('Timeout while waiting for PHP-FPM socket at ' . self::SOCKET);
214 | }
215 |
216 | // If the process has crashed we can stop immediately
217 | if (! $this->isFpmRunning()) {
218 | // The output of FPM is in the stderr of the Lambda process
219 | throw new Exception('PHP-FPM failed to start');
220 | }
221 | }
222 | }
223 |
224 | private function isReady(): bool
225 | {
226 | clearstatcache(false, self::SOCKET);
227 |
228 | return file_exists(self::SOCKET);
229 | }
230 |
231 | private function eventToFastCgiRequest(HttpRequestEvent $event, Context $context): ProvidesRequestData
232 | {
233 | $request = new FastCgiRequest($event->getMethod(), $this->handler, $event->getBody());
234 | $request->setRequestUri($event->getUri());
235 | $request->setRemoteAddress('127.0.0.1');
236 | $request->setRemotePort($event->getRemotePort());
237 | $request->setServerAddress('127.0.0.1');
238 | $request->setServerName($event->getServerName());
239 | $request->setServerProtocol($event->getProtocol());
240 | $request->setServerPort($event->getServerPort());
241 | $request->setCustomVar('PATH_INFO', $event->getPath());
242 | $request->setCustomVar('QUERY_STRING', $event->getQueryString());
243 | $request->setCustomVar('LAMBDA_INVOCATION_CONTEXT', json_encode($context, JSON_THROW_ON_ERROR));
244 | $request->setCustomVar('LAMBDA_REQUEST_CONTEXT', json_encode($event->getRequestContext(), JSON_THROW_ON_ERROR));
245 |
246 | $contentType = $event->getContentType();
247 | if ($contentType) {
248 | $request->setContentType($contentType);
249 | }
250 | foreach ($event->getHeaders() as $header => $values) {
251 | foreach ($values as $value) {
252 | $key = 'HTTP_' . strtoupper(str_replace('-', '_', (string) $header));
253 | $request->setCustomVar($key, $value);
254 | }
255 | }
256 |
257 | return $request;
258 | }
259 |
260 | /**
261 | * This method makes sure to kill any existing PHP-FPM process.
262 | *
263 | * @throws Exception
264 | */
265 | private function killExistingFpm(): void
266 | {
267 | // Never seen this happen but just in case
268 | if (! file_exists(self::PID_FILE)) {
269 | unlink(self::SOCKET);
270 | return;
271 | }
272 |
273 | $pid = (int) file_get_contents(self::PID_FILE);
274 |
275 | // Never seen this happen but just in case
276 | if ($pid <= 0) {
277 | echo "PHP-FPM's PID file contained an invalid PID, assuming PHP-FPM isn't running.\n";
278 | unlink(self::SOCKET);
279 | unlink(self::PID_FILE);
280 | return;
281 | }
282 |
283 | // Check if the process is running
284 | if (posix_getpgid($pid) === false) {
285 | // PHP-FPM is not running anymore, we can cleanup
286 | unlink(self::SOCKET);
287 | unlink(self::PID_FILE);
288 | return;
289 | }
290 |
291 | // The PID could be reused by our new process: let's not kill ourselves
292 | // See https://github.com/brefphp/bref/pull/645
293 | if ($pid === posix_getpid()) {
294 | unlink(self::SOCKET);
295 | unlink(self::PID_FILE);
296 | return;
297 | }
298 |
299 | echo "PHP-FPM seems to be running already. This might be because Lambda stopped the bootstrap process but didn't leave us an opportunity to stop PHP-FPM (did Lambda timeout?). Stopping PHP-FPM now to restart from a blank slate.\n";
300 |
301 | // The previous PHP-FPM process is running, let's try to kill it properly
302 | $result = posix_kill($pid, self::SIGTERM);
303 | if ($result === false) {
304 | echo "PHP-FPM's PID file contained a PID that doesn't exist, assuming PHP-FPM isn't running.\n";
305 | unlink(self::SOCKET);
306 | unlink(self::PID_FILE);
307 | return;
308 | }
309 | $this->waitUntilStopped($pid);
310 | unlink(self::SOCKET);
311 | unlink(self::PID_FILE);
312 | }
313 |
314 | /**
315 | * Wait until PHP-FPM has stopped.
316 | *
317 | * @throws Exception
318 | */
319 | private function waitUntilStopped(int $pid): void
320 | {
321 | $wait = 5000; // 5ms
322 | $timeout = 1000000; // 1 sec
323 | $elapsed = 0;
324 | while (posix_getpgid($pid) !== false) {
325 | usleep($wait);
326 | $elapsed += $wait;
327 | if ($elapsed > $timeout) {
328 | throw new Exception('Timeout while waiting for PHP-FPM to stop');
329 | }
330 | }
331 | }
332 |
333 | /**
334 | * Return an array of the response headers.
335 | */
336 | private function getResponseHeaders(ProvidesResponseData $response): array
337 | {
338 | return array_change_key_case($response->getHeaders(), CASE_LOWER);
339 | }
340 |
341 | public function stopFpm(float $timeout): void
342 | {
343 | if (! $this->fpm) {
344 | return;
345 | }
346 |
347 | $timeoutMicro = microtime(true) + $timeout;
348 | if ($this->isFpmRunning()) {
349 | $pid = proc_get_status($this->fpm)['pid'];
350 | // SIGTERM
351 | @posix_kill($pid, 15);
352 | do {
353 | usleep(1000);
354 | // @phpstan-ignore-next-line
355 | } while ($this->isFpmRunning() && microtime(true) < $timeoutMicro);
356 |
357 | // @phpstan-ignore-next-line
358 | if ($this->isFpmRunning()) {
359 | // SIGKILL
360 | @posix_kill($pid, 9);
361 | usleep(1000);
362 | }
363 | }
364 |
365 | proc_close($this->fpm);
366 | $this->fpm = null;
367 | }
368 |
369 | private function isFpmRunning(): bool
370 | {
371 | return $this->fpm && proc_get_status($this->fpm)['running'];
372 | }
373 | }
374 |
--------------------------------------------------------------------------------
/src/Event/Http/HttpRequestEvent.php:
--------------------------------------------------------------------------------
1 | method = strtoupper($event['httpMethod']);
32 | } elseif (isset($event['requestContext']['http']['method'])) {
33 | // version 2.0 - https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.proxy-format
34 | $this->method = strtoupper($event['requestContext']['http']['method']);
35 | } else {
36 | throw new InvalidLambdaEvent('API Gateway or ALB', $event);
37 | }
38 |
39 | $this->payloadVersion = (float) ($event['version'] ?? '1.0');
40 | $this->event = $event;
41 | $this->queryString = $this->rebuildQueryString();
42 | $this->headers = $this->extractHeaders();
43 | }
44 |
45 | public function toArray(): array
46 | {
47 | return $this->event;
48 | }
49 |
50 | public function getBody(): string
51 | {
52 | $requestBody = $this->event['body'] ?? '';
53 | if ($this->event['isBase64Encoded'] ?? false) {
54 | $requestBody = base64_decode($requestBody);
55 | }
56 | return $requestBody;
57 | }
58 |
59 | public function getMethod(): string
60 | {
61 | return $this->method;
62 | }
63 |
64 | public function getHeaders(): array
65 | {
66 | return $this->headers;
67 | }
68 |
69 | public function hasMultiHeader(): bool
70 | {
71 | if ($this->isFormatV2()) {
72 | return false;
73 | }
74 |
75 | return isset($this->event['multiValueHeaders']);
76 | }
77 |
78 | public function getProtocol(): string
79 | {
80 | return $this->event['requestContext']['protocol'] ?? 'HTTP/1.1';
81 | }
82 |
83 | public function getProtocolVersion(): string
84 | {
85 | return ltrim($this->getProtocol(), 'HTTP/');
86 | }
87 |
88 | public function getContentType(): ?string
89 | {
90 | return $this->headers['content-type'][0] ?? null;
91 | }
92 |
93 | public function getRemotePort(): int
94 | {
95 | return (int) ($this->headers['x-forwarded-port'][0] ?? 80);
96 | }
97 |
98 | /**
99 | * @return array{string, string}|array{null, null}
100 | */
101 | public function getBasicAuthCredentials(): array
102 | {
103 | $authorizationHeader = trim($this->headers['authorization'][0] ?? '');
104 |
105 | if (! str_starts_with($authorizationHeader, 'Basic ')) {
106 | return [null, null];
107 | }
108 |
109 | $auth = base64_decode(trim(explode(' ', $authorizationHeader)[1]));
110 |
111 | if (! $auth || ! strpos($auth, ':')) {
112 | return [null, null];
113 | }
114 |
115 | return explode(':', $auth, 2);
116 | }
117 |
118 | public function getServerPort(): int
119 | {
120 | return (int) ($this->headers['x-forwarded-port'][0] ?? 80);
121 | }
122 |
123 | public function getServerName(): string
124 | {
125 | return $this->headers['host'][0] ?? 'localhost';
126 | }
127 |
128 | public function getPath(): string
129 | {
130 | if ($this->isFormatV2()) {
131 | return $this->event['rawPath'] ?? '/';
132 | }
133 |
134 | /**
135 | * $event['path'] contains the URL always without the stage prefix.
136 | * $event['requestContext']['path'] contains the URL always with the stage prefix.
137 | * None of the represents the real URL because:
138 | * - the native API Gateway URL has the stage (`/dev`)
139 | * - with a custom domain, the URL doesn't have the stage (`/`)
140 | * - with CloudFront in front of AG, the URL doesn't have the stage (`/`)
141 | * Because it's hard to detect whether CloudFront is used, we will go with the "non-prefixed" URL ($event['path'])
142 | * as it's the one most likely used in production (because in production we use custom domains).
143 | * Since Bref now recommends HTTP APIs (that don't have a stage prefix), this problem will not be common anyway.
144 | * Full history:
145 | * - https://github.com/brefphp/bref/issues/67
146 | * - https://github.com/brefphp/bref/issues/309
147 | * - https://github.com/brefphp/bref/pull/794
148 | */
149 | return $this->event['path'] ?? '/';
150 | }
151 |
152 | public function getUri(): string
153 | {
154 | $queryString = $this->queryString;
155 | $uri = $this->getPath();
156 | if (! empty($queryString)) {
157 | $uri .= '?' . $queryString;
158 | }
159 | return $uri;
160 | }
161 |
162 | public function getQueryString(): string
163 | {
164 | return $this->queryString;
165 | }
166 |
167 | public function getQueryParameters(): array
168 | {
169 | return self::queryStringToArray($this->queryString);
170 | }
171 |
172 | public function getRequestContext(): array
173 | {
174 | return $this->event['requestContext'] ?? [];
175 | }
176 |
177 | public function getCookies(): array
178 | {
179 | if ($this->isFormatV2()) {
180 | $cookieParts = $this->event['cookies'] ?? [];
181 | } else {
182 | if (! isset($this->headers['cookie'])) {
183 | return [];
184 | }
185 | // Multiple "Cookie" headers are not authorized
186 | // https://stackoverflow.com/questions/16305814/are-multiple-cookie-headers-allowed-in-an-http-request
187 | $cookieHeader = $this->headers['cookie'][0];
188 | $cookieParts = explode('; ', $cookieHeader);
189 | }
190 |
191 | $cookies = [];
192 | foreach ($cookieParts as $cookiePart) {
193 | $explode = explode('=', $cookiePart, 2);
194 | if (count($explode) !== 2) {
195 | continue;
196 | }
197 | [$cookieName, $cookieValue] = $explode;
198 | $cookies[$cookieName] = urldecode($cookieValue);
199 | }
200 | return $cookies;
201 | }
202 |
203 | /**
204 | * @return array
205 | */
206 | public function getPathParameters(): array
207 | {
208 | return $this->event['pathParameters'] ?? [];
209 | }
210 |
211 | public function getSourceIp(): string
212 | {
213 | if ($this->isFormatV2()) {
214 | return $this->event['requestContext']['http']['sourceIp'] ?? '127.0.0.1';
215 | }
216 |
217 | return $this->event['requestContext']['identity']['sourceIp'] ?? '127.0.0.1';
218 | }
219 |
220 | private function rebuildQueryString(): string
221 | {
222 | if ($this->isFormatV2()) {
223 | $queryString = $this->event['rawQueryString'] ?? '';
224 | // We re-parse the query string to make sure it is URL-encoded
225 | // Why? To match the format we get when using PHP outside of Lambda (we get the query string URL-encoded)
226 | return http_build_query(self::queryStringToArray($queryString), '', '&', \PHP_QUERY_RFC3986);
227 | }
228 |
229 | // It is likely that we do not need to differentiate between API Gateway (Version 1) and ALB. However,
230 | // it would lead to a breaking change since the current implementation for API Gateway does not
231 | // support MultiValue query string. This way, the code is fully backward-compatible while
232 | // offering complete support for multi value query parameters on ALB. Later on there can
233 | // be a feature flag that allows API Gateway users to opt in to complete support as well.
234 | if (isset($this->event['requestContext']['elb'])) {
235 | // AWS differs between ALB with multiValue enabled or not (docs: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers)
236 | $queryParameters = $this->event['multiValueQueryStringParameters'] ?? $this->event['queryStringParameters'] ?? [];
237 |
238 | $queryString = '';
239 |
240 | // AWS always deliver the list of query parameters as an array. Let's loop through all of the
241 | // query parameters available and parse them to get their original URL decoded values.
242 | foreach ($queryParameters as $key => $values) {
243 | // If multi-value is disabled, $values is a string containing the last parameter sent.
244 | // If multi-value is enabled, $values is *always* an array containing a list of parameters per key.
245 | // Even if we only send 1 parameter (e.g. my_param=1), AWS will still send an array [1] for my_param
246 | // when multi-value is enabled.
247 | // By forcing $values to be an array, we can be consistent with both scenarios by always parsing
248 | // all values available on a given key.
249 | $values = (array) $values;
250 |
251 | // Let's go ahead and undo AWS's work and rebuild the original string that formed the
252 | // Query Parameters so that php's native function `parse_str` can automatically
253 | // decode all keys and all values. The result is a PHP array with decoded
254 | // keys and values. See https://github.com/brefphp/bref/pull/693
255 | foreach ($values as $value) {
256 | $queryString .= $key . '=' . $value . '&';
257 | }
258 | }
259 |
260 | // queryStringToArray() will automatically `urldecode` any value that needs decoding. This will allow parameters
261 | // like `?my_param[bref][]=first&my_param[bref][]=second` to properly work.
262 | return http_build_query(self::queryStringToArray($queryString), '', '&', \PHP_QUERY_RFC3986);
263 | }
264 |
265 | if (isset($this->event['multiValueQueryStringParameters']) && $this->event['multiValueQueryStringParameters']) {
266 | $queryParameterStr = [];
267 | // go through the params and url-encode the values, to build up a complete query-string
268 | foreach ($this->event['multiValueQueryStringParameters'] as $key => $value) {
269 | foreach ($value as $v) {
270 | $queryParameterStr[] = $key . '=' . urlencode($v);
271 | }
272 | }
273 |
274 | // re-parse the query-string so it matches the format used when using PHP outside of Lambda
275 | // this is particularly important when using multi-value params - eg. myvar[]=2&myvar=3 ... = [2, 3]
276 | $queryParameters = self::queryStringToArray(implode('&', $queryParameterStr));
277 |
278 | return http_build_query($queryParameters, '', '&', \PHP_QUERY_RFC3986);
279 | }
280 |
281 | if (empty($this->event['queryStringParameters'])) {
282 | return '';
283 | }
284 |
285 | /*
286 | * Watch out: do not use $event['queryStringParameters'] directly!
287 | *
288 | * (that is no longer the case here but it was in the past with Bref 0.2)
289 | *
290 | * queryStringParameters does not handle correctly arrays in parameters
291 | * ?array[key]=value gives ['array[key]' => 'value'] while we want ['array' => ['key' = > 'value']]
292 | * In that case we should recreate the original query string and use parse_str which handles correctly arrays
293 | */
294 | return http_build_query($this->event['queryStringParameters'], '', '&', \PHP_QUERY_RFC3986);
295 | }
296 |
297 | private function extractHeaders(): array
298 | {
299 | // Normalize headers
300 | if (isset($this->event['multiValueHeaders'])) {
301 | $headers = $this->event['multiValueHeaders'];
302 | } else {
303 | $headers = $this->event['headers'] ?? [];
304 | // Turn the headers array into a multi-value array to simplify the code below
305 | $headers = array_map(function ($value): array {
306 | return [$value];
307 | }, $headers);
308 | }
309 | $headers = array_change_key_case($headers, CASE_LOWER);
310 |
311 | $hasBody = ! empty($this->event['body']);
312 | // See https://stackoverflow.com/a/5519834/245552
313 | if ($hasBody && ! isset($headers['content-type'])) {
314 | $headers['content-type'] = ['application/x-www-form-urlencoded'];
315 | }
316 |
317 | // Auto-add the Content-Length header if it wasn't provided
318 | // See https://github.com/brefphp/bref/issues/162
319 | if ($hasBody && ! isset($headers['content-length'])) {
320 | $headers['content-length'] = [strlen($this->getBody())];
321 | }
322 |
323 | // Cookies are separated from headers in payload v2, we re-add them in there
324 | // so that we have the full original HTTP request
325 | if (! empty($this->event['cookies']) && $this->isFormatV2()) {
326 | $cookieHeader = implode('; ', $this->event['cookies']);
327 | $headers['cookie'] = [$cookieHeader];
328 | }
329 |
330 | return $headers;
331 | }
332 |
333 | /**
334 | * See https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.proxy-format
335 | */
336 | public function isFormatV2(): bool
337 | {
338 | return $this->payloadVersion === 2.0;
339 | }
340 |
341 | /**
342 | * When keys within a URL query string contain dots, PHP's parse_str() method
343 | * converts them to underscores. This method works around this issue so the
344 | * requested query array returns the proper keys with dots.
345 | *
346 | * @return array
347 | *
348 | * @see https://github.com/brefphp/bref/issues/756
349 | * @see https://github.com/brefphp/bref/pull/1437
350 | */
351 | private static function queryStringToArray(string $query): array
352 | {
353 | return Query::fromString($query)->toArray();
354 | }
355 | }
356 |
--------------------------------------------------------------------------------
/layers.json:
--------------------------------------------------------------------------------
1 | {
2 | "php-84": {
3 | "ca-central-1": "35",
4 | "eu-central-1": "35",
5 | "eu-north-1": "35",
6 | "eu-west-1": "35",
7 | "eu-west-2": "35",
8 | "eu-west-3": "35",
9 | "sa-east-1": "35",
10 | "us-east-1": "35",
11 | "us-east-2": "35",
12 | "us-west-1": "35",
13 | "us-west-2": "35",
14 | "ap-east-1": "35",
15 | "ap-south-1": "35",
16 | "ap-northeast-1": "35",
17 | "ap-northeast-2": "35",
18 | "ap-northeast-3": "35",
19 | "ap-southeast-1": "35",
20 | "ap-southeast-2": "35",
21 | "eu-south-1": "35",
22 | "eu-south-2": "35",
23 | "af-south-1": "35",
24 | "me-south-1": "35"
25 | },
26 | "php-84-fpm": {
27 | "ca-central-1": "35",
28 | "eu-central-1": "35",
29 | "eu-north-1": "35",
30 | "eu-west-1": "35",
31 | "eu-west-2": "35",
32 | "eu-west-3": "35",
33 | "sa-east-1": "35",
34 | "us-east-1": "35",
35 | "us-east-2": "35",
36 | "us-west-1": "35",
37 | "us-west-2": "35",
38 | "ap-east-1": "35",
39 | "ap-south-1": "35",
40 | "ap-northeast-1": "35",
41 | "ap-northeast-2": "35",
42 | "ap-northeast-3": "35",
43 | "ap-southeast-1": "35",
44 | "ap-southeast-2": "35",
45 | "eu-south-1": "35",
46 | "eu-south-2": "35",
47 | "af-south-1": "35",
48 | "me-south-1": "35"
49 | },
50 | "php-83": {
51 | "ca-central-1": "63",
52 | "eu-central-1": "63",
53 | "eu-north-1": "63",
54 | "eu-west-1": "63",
55 | "eu-west-2": "63",
56 | "eu-west-3": "63",
57 | "sa-east-1": "63",
58 | "us-east-1": "63",
59 | "us-east-2": "63",
60 | "us-west-1": "63",
61 | "us-west-2": "63",
62 | "ap-east-1": "63",
63 | "ap-south-1": "63",
64 | "ap-northeast-1": "63",
65 | "ap-northeast-2": "63",
66 | "ap-northeast-3": "63",
67 | "ap-southeast-1": "63",
68 | "ap-southeast-2": "63",
69 | "eu-south-1": "63",
70 | "eu-south-2": "63",
71 | "af-south-1": "63",
72 | "me-south-1": "63"
73 | },
74 | "php-83-fpm": {
75 | "ca-central-1": "63",
76 | "eu-central-1": "63",
77 | "eu-north-1": "63",
78 | "eu-west-1": "63",
79 | "eu-west-2": "63",
80 | "eu-west-3": "63",
81 | "sa-east-1": "63",
82 | "us-east-1": "63",
83 | "us-east-2": "63",
84 | "us-west-1": "63",
85 | "us-west-2": "63",
86 | "ap-east-1": "63",
87 | "ap-south-1": "63",
88 | "ap-northeast-1": "63",
89 | "ap-northeast-2": "63",
90 | "ap-northeast-3": "63",
91 | "ap-southeast-1": "63",
92 | "ap-southeast-2": "63",
93 | "eu-south-1": "63",
94 | "eu-south-2": "63",
95 | "af-south-1": "63",
96 | "me-south-1": "63"
97 | },
98 | "php-82": {
99 | "ca-central-1": "107",
100 | "eu-central-1": "107",
101 | "eu-north-1": "107",
102 | "eu-west-1": "107",
103 | "eu-west-2": "107",
104 | "eu-west-3": "107",
105 | "sa-east-1": "107",
106 | "us-east-1": "107",
107 | "us-east-2": "107",
108 | "us-west-1": "107",
109 | "us-west-2": "107",
110 | "ap-east-1": "107",
111 | "ap-south-1": "107",
112 | "ap-northeast-1": "107",
113 | "ap-northeast-2": "107",
114 | "ap-northeast-3": "107",
115 | "ap-southeast-1": "107",
116 | "ap-southeast-2": "107",
117 | "eu-south-1": "107",
118 | "eu-south-2": "106",
119 | "af-south-1": "107",
120 | "me-south-1": "107"
121 | },
122 | "php-82-fpm": {
123 | "ca-central-1": "107",
124 | "eu-central-1": "107",
125 | "eu-north-1": "107",
126 | "eu-west-1": "107",
127 | "eu-west-2": "107",
128 | "eu-west-3": "107",
129 | "sa-east-1": "107",
130 | "us-east-1": "107",
131 | "us-east-2": "107",
132 | "us-west-1": "107",
133 | "us-west-2": "107",
134 | "ap-east-1": "107",
135 | "ap-south-1": "107",
136 | "ap-northeast-1": "107",
137 | "ap-northeast-2": "107",
138 | "ap-northeast-3": "107",
139 | "ap-southeast-1": "107",
140 | "ap-southeast-2": "107",
141 | "eu-south-1": "107",
142 | "eu-south-2": "106",
143 | "af-south-1": "107",
144 | "me-south-1": "107"
145 | },
146 | "php-81": {
147 | "ca-central-1": "118",
148 | "eu-central-1": "118",
149 | "eu-north-1": "118",
150 | "eu-west-1": "118",
151 | "eu-west-2": "118",
152 | "eu-west-3": "118",
153 | "sa-east-1": "118",
154 | "us-east-1": "118",
155 | "us-east-2": "118",
156 | "us-west-1": "118",
157 | "us-west-2": "118",
158 | "ap-east-1": "110",
159 | "ap-south-1": "117",
160 | "ap-northeast-1": "117",
161 | "ap-northeast-2": "117",
162 | "ap-northeast-3": "117",
163 | "ap-southeast-1": "117",
164 | "ap-southeast-2": "117",
165 | "eu-south-1": "110",
166 | "eu-south-2": "107",
167 | "af-south-1": "110",
168 | "me-south-1": "110"
169 | },
170 | "php-81-fpm": {
171 | "ca-central-1": "117",
172 | "eu-central-1": "117",
173 | "eu-north-1": "118",
174 | "eu-west-1": "118",
175 | "eu-west-2": "117",
176 | "eu-west-3": "117",
177 | "sa-east-1": "117",
178 | "us-east-1": "118",
179 | "us-east-2": "117",
180 | "us-west-1": "117",
181 | "us-west-2": "118",
182 | "ap-east-1": "110",
183 | "ap-south-1": "116",
184 | "ap-northeast-1": "117",
185 | "ap-northeast-2": "116",
186 | "ap-northeast-3": "116",
187 | "ap-southeast-1": "116",
188 | "ap-southeast-2": "116",
189 | "eu-south-1": "109",
190 | "eu-south-2": "106",
191 | "af-south-1": "109",
192 | "me-south-1": "109"
193 | },
194 | "php-80": {
195 | "ca-central-1": "121",
196 | "eu-central-1": "120",
197 | "eu-north-1": "121",
198 | "eu-west-1": "121",
199 | "eu-west-2": "121",
200 | "eu-west-3": "121",
201 | "sa-east-1": "121",
202 | "us-east-1": "121",
203 | "us-east-2": "121",
204 | "us-west-1": "121",
205 | "us-west-2": "121",
206 | "ap-east-1": "111",
207 | "ap-south-1": "120",
208 | "ap-northeast-1": "118",
209 | "ap-northeast-2": "117",
210 | "ap-northeast-3": "118",
211 | "ap-southeast-1": "117",
212 | "ap-southeast-2": "119",
213 | "eu-south-1": "111",
214 | "eu-south-2": "107",
215 | "af-south-1": "111",
216 | "me-south-1": "111"
217 | },
218 | "php-80-fpm": {
219 | "ca-central-1": "118",
220 | "eu-central-1": "118",
221 | "eu-north-1": "118",
222 | "eu-west-1": "118",
223 | "eu-west-2": "118",
224 | "eu-west-3": "118",
225 | "sa-east-1": "118",
226 | "us-east-1": "118",
227 | "us-east-2": "118",
228 | "us-west-1": "118",
229 | "us-west-2": "118",
230 | "ap-east-1": "110",
231 | "ap-south-1": "117",
232 | "ap-northeast-1": "117",
233 | "ap-northeast-2": "117",
234 | "ap-northeast-3": "117",
235 | "ap-southeast-1": "117",
236 | "ap-southeast-2": "117",
237 | "eu-south-1": "110",
238 | "eu-south-2": "107",
239 | "af-south-1": "110",
240 | "me-south-1": "110"
241 | },
242 | "arm-php-84": {
243 | "ca-central-1": "35",
244 | "eu-central-1": "35",
245 | "eu-north-1": "35",
246 | "eu-west-1": "35",
247 | "eu-west-2": "35",
248 | "eu-west-3": "35",
249 | "sa-east-1": "35",
250 | "us-east-1": "35",
251 | "us-east-2": "35",
252 | "us-west-1": "35",
253 | "us-west-2": "35",
254 | "ap-east-1": "35",
255 | "ap-south-1": "35",
256 | "ap-northeast-1": "35",
257 | "ap-northeast-2": "35",
258 | "ap-northeast-3": "35",
259 | "ap-southeast-1": "35",
260 | "ap-southeast-2": "35",
261 | "eu-south-1": "35",
262 | "eu-south-2": "35",
263 | "af-south-1": "35",
264 | "me-south-1": "35"
265 | },
266 | "arm-php-84-fpm": {
267 | "ca-central-1": "35",
268 | "eu-central-1": "35",
269 | "eu-north-1": "35",
270 | "eu-west-1": "35",
271 | "eu-west-2": "35",
272 | "eu-west-3": "35",
273 | "sa-east-1": "35",
274 | "us-east-1": "35",
275 | "us-east-2": "35",
276 | "us-west-1": "35",
277 | "us-west-2": "35",
278 | "ap-east-1": "35",
279 | "ap-south-1": "35",
280 | "ap-northeast-1": "35",
281 | "ap-northeast-2": "35",
282 | "ap-northeast-3": "35",
283 | "ap-southeast-1": "35",
284 | "ap-southeast-2": "35",
285 | "eu-south-1": "35",
286 | "eu-south-2": "35",
287 | "af-south-1": "35",
288 | "me-south-1": "35"
289 | },
290 | "arm-php-83": {
291 | "ca-central-1": "63",
292 | "eu-central-1": "63",
293 | "eu-north-1": "63",
294 | "eu-west-1": "63",
295 | "eu-west-2": "63",
296 | "eu-west-3": "63",
297 | "sa-east-1": "63",
298 | "us-east-1": "63",
299 | "us-east-2": "63",
300 | "us-west-1": "63",
301 | "us-west-2": "63",
302 | "ap-east-1": "63",
303 | "ap-south-1": "63",
304 | "ap-northeast-1": "63",
305 | "ap-northeast-2": "63",
306 | "ap-northeast-3": "63",
307 | "ap-southeast-1": "63",
308 | "ap-southeast-2": "63",
309 | "eu-south-1": "63",
310 | "eu-south-2": "63",
311 | "af-south-1": "63",
312 | "me-south-1": "63"
313 | },
314 | "arm-php-83-fpm": {
315 | "ca-central-1": "62",
316 | "eu-central-1": "62",
317 | "eu-north-1": "62",
318 | "eu-west-1": "62",
319 | "eu-west-2": "62",
320 | "eu-west-3": "62",
321 | "sa-east-1": "62",
322 | "us-east-1": "63",
323 | "us-east-2": "62",
324 | "us-west-1": "62",
325 | "us-west-2": "62",
326 | "ap-east-1": "62",
327 | "ap-south-1": "62",
328 | "ap-northeast-1": "62",
329 | "ap-northeast-2": "62",
330 | "ap-northeast-3": "62",
331 | "ap-southeast-1": "62",
332 | "ap-southeast-2": "62",
333 | "eu-south-1": "62",
334 | "eu-south-2": "62",
335 | "af-south-1": "62",
336 | "me-south-1": "62"
337 | },
338 | "arm-php-82": {
339 | "ca-central-1": "95",
340 | "eu-central-1": "95",
341 | "eu-north-1": "95",
342 | "eu-west-1": "95",
343 | "eu-west-2": "95",
344 | "eu-west-3": "95",
345 | "sa-east-1": "95",
346 | "us-east-1": "95",
347 | "us-east-2": "95",
348 | "us-west-1": "95",
349 | "us-west-2": "95",
350 | "ap-east-1": "95",
351 | "ap-south-1": "95",
352 | "ap-northeast-1": "95",
353 | "ap-northeast-2": "95",
354 | "ap-northeast-3": "95",
355 | "ap-southeast-1": "95",
356 | "ap-southeast-2": "95",
357 | "eu-south-1": "95",
358 | "eu-south-2": "95",
359 | "af-south-1": "95",
360 | "me-south-1": "95"
361 | },
362 | "arm-php-82-fpm": {
363 | "ca-central-1": "95",
364 | "eu-central-1": "95",
365 | "eu-north-1": "95",
366 | "eu-west-1": "95",
367 | "eu-west-2": "95",
368 | "eu-west-3": "95",
369 | "sa-east-1": "95",
370 | "us-east-1": "95",
371 | "us-east-2": "95",
372 | "us-west-1": "95",
373 | "us-west-2": "95",
374 | "ap-east-1": "95",
375 | "ap-south-1": "95",
376 | "ap-northeast-1": "95",
377 | "ap-northeast-2": "95",
378 | "ap-northeast-3": "95",
379 | "ap-southeast-1": "95",
380 | "ap-southeast-2": "95",
381 | "eu-south-1": "95",
382 | "eu-south-2": "95",
383 | "af-south-1": "95",
384 | "me-south-1": "95"
385 | },
386 | "arm-php-81": {
387 | "ca-central-1": "98",
388 | "eu-central-1": "98",
389 | "eu-north-1": "98",
390 | "eu-west-1": "98",
391 | "eu-west-2": "98",
392 | "eu-west-3": "98",
393 | "sa-east-1": "98",
394 | "us-east-1": "98",
395 | "us-east-2": "98",
396 | "us-west-1": "98",
397 | "us-west-2": "98",
398 | "ap-east-1": "98",
399 | "ap-south-1": "98",
400 | "ap-northeast-1": "98",
401 | "ap-northeast-2": "98",
402 | "ap-northeast-3": "98",
403 | "ap-southeast-1": "98",
404 | "ap-southeast-2": "98",
405 | "eu-south-1": "98",
406 | "eu-south-2": "98",
407 | "af-south-1": "98",
408 | "me-south-1": "98"
409 | },
410 | "arm-php-81-fpm": {
411 | "ca-central-1": "98",
412 | "eu-central-1": "98",
413 | "eu-north-1": "98",
414 | "eu-west-1": "98",
415 | "eu-west-2": "98",
416 | "eu-west-3": "98",
417 | "sa-east-1": "98",
418 | "us-east-1": "98",
419 | "us-east-2": "98",
420 | "us-west-1": "98",
421 | "us-west-2": "98",
422 | "ap-east-1": "98",
423 | "ap-south-1": "98",
424 | "ap-northeast-1": "98",
425 | "ap-northeast-2": "98",
426 | "ap-northeast-3": "98",
427 | "ap-southeast-1": "98",
428 | "ap-southeast-2": "98",
429 | "eu-south-1": "98",
430 | "eu-south-2": "98",
431 | "af-south-1": "98",
432 | "me-south-1": "98"
433 | },
434 | "arm-php-80": {
435 | "ca-central-1": "120",
436 | "eu-central-1": "119",
437 | "eu-north-1": "120",
438 | "eu-west-1": "120",
439 | "eu-west-2": "120",
440 | "eu-west-3": "120",
441 | "sa-east-1": "120",
442 | "us-east-1": "120",
443 | "us-east-2": "120",
444 | "us-west-1": "120",
445 | "us-west-2": "120",
446 | "ap-east-1": "112",
447 | "ap-south-1": "119",
448 | "ap-northeast-1": "119",
449 | "ap-northeast-2": "119",
450 | "ap-northeast-3": "119",
451 | "ap-southeast-1": "119",
452 | "ap-southeast-2": "119",
453 | "eu-south-1": "112",
454 | "eu-south-2": "108",
455 | "af-south-1": "112",
456 | "me-south-1": "112"
457 | },
458 | "arm-php-80-fpm": {
459 | "ca-central-1": "119",
460 | "eu-central-1": "118",
461 | "eu-north-1": "119",
462 | "eu-west-1": "119",
463 | "eu-west-2": "118",
464 | "eu-west-3": "118",
465 | "sa-east-1": "118",
466 | "us-east-1": "119",
467 | "us-east-2": "119",
468 | "us-west-1": "118",
469 | "us-west-2": "119",
470 | "ap-east-1": "111",
471 | "ap-south-1": "117",
472 | "ap-northeast-1": "118",
473 | "ap-northeast-2": "117",
474 | "ap-northeast-3": "117",
475 | "ap-southeast-1": "117",
476 | "ap-southeast-2": "117",
477 | "eu-south-1": "110",
478 | "eu-south-2": "107",
479 | "af-south-1": "111",
480 | "me-south-1": "110"
481 | },
482 | "console": {
483 | "ca-central-1": "116",
484 | "eu-central-1": "116",
485 | "eu-north-1": "116",
486 | "eu-west-1": "116",
487 | "eu-west-2": "116",
488 | "eu-west-3": "116",
489 | "sa-east-1": "116",
490 | "us-east-1": "116",
491 | "us-east-2": "116",
492 | "us-west-1": "116",
493 | "us-west-2": "116",
494 | "ap-east-1": "108",
495 | "ap-south-1": "115",
496 | "ap-northeast-1": "115",
497 | "ap-northeast-2": "115",
498 | "ap-northeast-3": "115",
499 | "ap-southeast-1": "115",
500 | "ap-southeast-2": "115",
501 | "eu-south-1": "108",
502 | "eu-south-2": "105",
503 | "af-south-1": "108",
504 | "me-south-1": "108"
505 | }
506 | }
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const {listLayers} = require('./plugin/layers');
4 | const {runConsole} = require('./plugin/run-console');
5 | const {runLocal} = require('./plugin/local');
6 | const {warnIfUsingSecretsWithoutTheBrefDependency} = require('./plugin/secrets');
7 | const fs = require('fs');
8 | const path = require('path');
9 |
10 | // Disable `sls` promoting the Serverless Console because it's not compatible with PHP, it's tripping users up
11 | if (!process.env.SLS_NOTIFICATIONS_MODE) {
12 | process.env.SLS_NOTIFICATIONS_MODE = 'upgrades-only';
13 | }
14 |
15 | /**
16 | * This file declares a plugin for the Serverless framework.
17 | *
18 | * This lets us define variables and helpers to simplify creating PHP applications.
19 | */
20 |
21 | class ServerlessPlugin {
22 | /**
23 | * @param {import('./plugin/serverless').Serverless} serverless
24 | * @param {import('./plugin/serverless').CliOptions} options
25 | * @param {import('./plugin/serverless').ServerlessUtils} utils
26 | */
27 | constructor(serverless, options, utils) {
28 | this.serverless = serverless;
29 | this.provider = this.serverless.getProvider('aws');
30 |
31 | if (!utils) {
32 | throw new serverless.classes.Error('Bref requires Serverless Framework v3, but an older v2 version is running.\nPlease upgrade to Serverless Framework v3.');
33 | }
34 | this.utils = utils;
35 |
36 | // Automatically enable faster deployments (unless a value is already set)
37 | // https://www.serverless.com/framework/docs/providers/aws/guide/deploying#deployment-method
38 | if (! serverless.service.provider.deploymentMethod) {
39 | serverless.service.provider.deploymentMethod = 'direct';
40 | }
41 |
42 | const filename = path.resolve(__dirname, 'layers.json');
43 | /** @type {Record>} */
44 | this.layers = JSON.parse(fs.readFileSync(filename).toString());
45 |
46 | this.runtimes = Object.keys(this.layers)
47 | .filter(name => !name.startsWith('arm-'));
48 | // Console runtimes must have a PHP version provided
49 | this.runtimes = this.runtimes.filter(name => name !== 'console');
50 | this.runtimes.push('php-80-console', 'php-81-console', 'php-82-console', 'php-83-console', 'php-84-console');
51 |
52 | this.checkCompatibleRuntime();
53 |
54 | serverless.configSchemaHandler.schema.definitions.awsLambdaRuntime.enum.push(...this.runtimes);
55 | serverless.configSchemaHandler.defineTopLevelProperty('bref', {
56 | type: 'object',
57 | });
58 |
59 | // Declare `${bref:xxx}` variables
60 | // See https://www.serverless.com/framework/docs/guides/plugins/custom-variables
61 | // noinspection JSUnusedGlobalSymbols
62 | /** @type {Record} */
63 | this.configurationVariablesSources = {
64 | bref: {
65 | resolve: async ({address, resolveConfigurationProperty, options}) => {
66 | // `address` and `params` reflect values configured with a variable: ${bref(param1, param2):address}
67 |
68 | // `options` is CLI options
69 | // `resolveConfigurationProperty` allows to access other configuration properties,
70 | // and guarantees to return a fully resolved form (even if property is configured with variables)
71 | /** @type {string} */
72 | // @ts-ignore
73 | const region = options.region || await resolveConfigurationProperty(['provider', 'region']);
74 |
75 | if (!address.startsWith('layer.')) {
76 | throw new serverless.classes.Error(`Unknown Bref variable \${bref:${address}}, the only supported syntax right now is \${bref:layer.XXX}`);
77 | }
78 |
79 | const layerName = address.substring('layer.'.length);
80 | return {
81 | value: this.getLayerArn(layerName, region),
82 | }
83 | }
84 | }
85 | };
86 |
87 | /** @type {import('./plugin/serverless').CommandsDefinition} */
88 | this.commands = {
89 | 'bref:cli': {
90 | usage: 'Runs a CLI command in AWS Lambda',
91 | lifecycleEvents: ['run'],
92 | options: {
93 | // Define the '--args' option with the '-a' shortcut
94 | args: {
95 | usage: 'Specify the arguments/options of the command to run on AWS Lambda',
96 | shortcut: 'a',
97 | type: 'string',
98 | },
99 | function: {
100 | usage: 'The name of the function to invoke (optional, auto-discovered by default)',
101 | shortcut: 'f',
102 | required: false,
103 | type: 'string',
104 | },
105 | },
106 | },
107 | 'bref:local': {
108 | usage: 'Runs a PHP Lambda function locally (better alternative to "serverless local")',
109 | lifecycleEvents: ['run'],
110 | options: {
111 | function: {
112 | usage: 'The name of the function to invoke',
113 | shortcut: 'f',
114 | required: true,
115 | type: 'string',
116 | },
117 | data: {
118 | usage: 'The data (as a JSON string) to pass to the handler',
119 | shortcut: 'd',
120 | type: 'string',
121 | },
122 | path: {
123 | usage: 'Path to JSON or YAML file holding input data (use either this or --data)',
124 | shortcut: 'p',
125 | type: 'string',
126 | },
127 | },
128 | },
129 | 'bref:layers': {
130 | usage: 'Displays the versions of the Bref layers',
131 | lifecycleEvents: ['show'],
132 | },
133 | };
134 |
135 | // noinspection JSUnusedGlobalSymbols
136 | /** @type {import('./plugin/serverless').HooksDefinition} */
137 | this.hooks = {
138 | 'initialize': () => {
139 | this.processPhpRuntimes();
140 | warnIfUsingSecretsWithoutTheBrefDependency(this.serverless, utils.log);
141 | try {
142 | this.telemetry();
143 | } catch (e) {
144 | // These errors should not stop the execution
145 | this.utils.log.verbose(`Could not send telemetry: ${e}`);
146 | }
147 | },
148 | // Custom commands
149 | 'bref:cli:run': () => runConsole(this.serverless, options),
150 | 'bref:local:run': () => runLocal(this.serverless, options),
151 | 'bref:layers:show': () => listLayers(this.serverless, utils.log),
152 | 'before:logs:logs': () => {
153 | utils.log(this.gray('View, tail, and search logs from all functions with https://bref.sh/cloud'));
154 | utils.log();
155 | },
156 | 'before:metrics:metrics': () => {
157 | utils.log(this.gray('View all your application\'s metrics with https://bref.sh/cloud'));
158 | utils.log();
159 | },
160 | };
161 |
162 | process.on('beforeExit', (code) => {
163 | const command = serverless.processedInput.commands[0] || '';
164 | // On successful deploy
165 | if (command.startsWith('deploy') && code === 0) {
166 | utils.log();
167 | utils.log(this.gray('Want a better experience than the AWS console? Try out https://bref.sh/cloud'));
168 | }
169 | });
170 | }
171 |
172 | /**
173 | * Process the `php-xx` runtimes to turn them into `provided.al2` runtimes + Bref layers.
174 | */
175 | processPhpRuntimes() {
176 | const includeBrefLayers = (runtime, existingLayers, isArm) => {
177 | let layerName = runtime;
178 | // Automatically use ARM layers if the function is deployed to an ARM architecture
179 | if (isArm) {
180 | layerName = 'arm-' + layerName;
181 | }
182 | if (layerName.endsWith('-console')) {
183 | layerName = layerName.substring(0, layerName.length - '-console'.length);
184 | existingLayers.unshift(this.getLayerArn('console', this.provider.getRegion()));
185 | existingLayers.unshift(this.getLayerArn(layerName, this.provider.getRegion()));
186 | } else {
187 | existingLayers.unshift(this.getLayerArn(layerName, this.provider.getRegion()));
188 | }
189 | return existingLayers;
190 | }
191 |
192 | const config = this.serverless.service;
193 | const isArmGlobally = config.provider.architecture === 'arm64';
194 | const isBrefRuntimeGlobally = this.runtimes.includes(config.provider.runtime || '');
195 |
196 | // Check functions config
197 | for (const f of Object.values(config.functions || {})) {
198 | if (
199 | (f.runtime && this.runtimes.includes(f.runtime)) ||
200 | (!f.runtime && isBrefRuntimeGlobally)
201 | ) {
202 | // The logic here is a bit custom:
203 | // If there are layers on the function, we preserve them
204 | let existingLayers = f.layers || []; // make sure it's an array
205 | // Else, we merge with the layers defined at the root.
206 | // Indeed, SF overrides the layers defined at the root with the ones defined on the function.
207 | if (existingLayers.length === 0) {
208 | // for some reason it's not always an array
209 | existingLayers = Array.from(config.provider.layers || []);
210 | }
211 |
212 | f.layers = includeBrefLayers(
213 | f.runtime || config.provider.runtime,
214 | existingLayers,
215 | f.architecture === 'arm64' || (isArmGlobally && !f.architecture),
216 | );
217 | f.runtime = 'provided.al2';
218 | }
219 | }
220 |
221 | // Check Lift constructs config
222 | for (const construct of Object.values(this.serverless.configurationInput.constructs || {})) {
223 | if (construct.type !== 'queue' && construct.type !== 'webhook') continue;
224 | const f = construct.type === 'queue' ? construct.worker : construct.authorizer;
225 | if (f && (f.runtime && this.runtimes.includes(f.runtime) || !f.runtime && isBrefRuntimeGlobally) ) {
226 | f.layers = includeBrefLayers(
227 | f.runtime || config.provider.runtime,
228 | f.layers || [], // make sure it's an array
229 | f.architecture === 'arm64' || (isArmGlobally && !f.architecture),
230 | );
231 | f.runtime = 'provided.al2';
232 | }
233 | }
234 | }
235 |
236 | checkCompatibleRuntime() {
237 | const errorMessage = 'Bref layers are not compatible with the "provided" runtime.\nYou have to use the "provided.al2" runtime instead in serverless.yml.\nMore details here: https://bref.sh/docs/news/01-bref-1.0.html#amazon-linux-2';
238 | if (this.serverless.service.provider.runtime === 'provided') {
239 | throw new this.serverless.classes.Error(errorMessage);
240 | }
241 | for (const [, f] of Object.entries(this.serverless.service.functions || {})) {
242 | if (f.runtime === 'provided') {
243 | throw new this.serverless.classes.Error(errorMessage);
244 | }
245 | }
246 | }
247 |
248 | /**
249 | * @param {string} layerName
250 | * @param {string} region
251 | * @returns {string}
252 | */
253 | getLayerArn(layerName, region) {
254 | if (! (layerName in this.layers)) {
255 | throw new this.serverless.classes.Error(`Unknown Bref layer named "${layerName}".\nIs that a typo? Check out https://bref.sh/docs/runtimes/ to see the correct name of Bref layers.`);
256 | }
257 | if (! (region in this.layers[layerName])) {
258 | throw new this.serverless.classes.Error(`There is no Bref layer named "${layerName}" in region "${region}".\nThat region may not be supported yet. Check out https://runtimes.bref.sh to see the list of supported regions.\nOpen an issue to ask for that region to be supported: https://github.com/brefphp/bref/issues`);
259 | }
260 | const version = this.layers[layerName][region];
261 | return `arn:aws:lambda:${region}:534081306603:layer:${layerName}:${version}`;
262 | }
263 |
264 | /**
265 | * Bref telemetry to estimate the number of users and which commands are most used.
266 | *
267 | * The data sent is anonymous, and sent over UDP.
268 | * Unlike TCP, UDP does not check that the message correctly arrived to the server.
269 | * It doesn't even establish a connection: the data is sent over the network and the code moves on to the next line.
270 | * That means that UDP is extremely fast (150 micro-seconds) and will not impact the CLI.
271 | * It can be disabled by setting the `SLS_TELEMETRY_DISABLED` environment variable to `1`.
272 | *
273 | * About UDP: https://en.wikipedia.org/wiki/User_Datagram_Protocol
274 | */
275 | telemetry() {
276 | // Respect the native env variable
277 | if (process.env.SLS_TELEMETRY_DISABLED) {
278 | return;
279 | }
280 |
281 | /** @type {{ get: (string) => string }} */
282 | // @ts-ignore
283 | const userConfig = require.main.require('@serverless/utils/config');
284 | /** @type {typeof import('ci-info')} */
285 | // @ts-ignore
286 | const ci = require.main.require('ci-info');
287 |
288 | let command = 'unknown';
289 | if (this.serverless.processedInput && this.serverless.processedInput.commands) {
290 | command = this.serverless.processedInput.commands.join(' ');
291 | }
292 |
293 | let timezone;
294 | try {
295 | timezone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
296 | } catch {
297 | // Pass silently
298 | }
299 |
300 | const payload = {
301 | cli: 'sls',
302 | v: 2, // Bref version
303 | c: command,
304 | ci: ci.isCI,
305 | install: userConfig.get('meta.created_at'),
306 | uid: userConfig.get('frameworkId'), // anonymous user ID created by the Serverless Framework
307 | tz: timezone,
308 | };
309 | const config = this.serverless.configurationInput;
310 | /** @type {string[]} */
311 | let plugins = [];
312 | if (this.serverless.service.plugins && 'modules' in this.serverless.service.plugins) {
313 | plugins = this.serverless.service.plugins.modules;
314 | } else if (this.serverless.service.plugins) {
315 | plugins = this.serverless.service.plugins;
316 | }
317 | // Lift construct types
318 | if (plugins.includes('serverless-lift') && typeof config.constructs === 'object') {
319 | payload.constructs = Object.values(config.constructs)
320 | .map((construct) => (typeof construct === 'object' && construct.type) ? construct.type : null)
321 | .filter(Boolean);
322 | }
323 |
324 | // PHP extensions
325 | const extensionLayers = [];
326 | const allLayers = [];
327 | if (config.provider && config.provider.layers && Array.isArray(config.provider.layers)) {
328 | allLayers.push(...config.provider.layers);
329 | }
330 | Object.values(config.functions || {}).forEach((f) => {
331 | if (f.layers && Array.isArray(f.layers)) {
332 | allLayers.push(...f.layers);
333 | }
334 | });
335 | if (allLayers.length > 0) {
336 | const layerRegex = /^arn:aws:lambda:[^:]+:403367587399:layer:([^:]+)-php-[^:]+:[^:]+$/;
337 | /** @type {string[]} */
338 | // @ts-ignore
339 | const extensionLayerArns = allLayers
340 | .filter((layer) => {
341 | return typeof layer === 'string'
342 | && layer.includes('403367587399');
343 | });
344 | for (const layer of extensionLayerArns) {
345 | // Extract the layer name from the ARN.
346 | // The ARN looks like this: arn:aws:lambda:us-east-2:403367587399:layer:amqp-php-81:12
347 | const match = layer.match(layerRegex);
348 | if (match && match[1] && ! extensionLayers.includes(match[1])) {
349 | extensionLayers.push(match[1]);
350 | }
351 | }
352 | }
353 | if (extensionLayers.length > 0) {
354 | payload.ext = extensionLayers;
355 | }
356 |
357 | // Send as a UDP packet to 108.128.197.71:8888
358 | const dgram = require('dgram');
359 | const client = dgram.createSocket('udp4');
360 | // This IP address is the Bref server.
361 | // If this server is down or unreachable, there should be no difference in overhead
362 | // or execution time.
363 | client.send(JSON.stringify(payload), 8888, '108.128.197.71', (err) => {
364 | if (err) {
365 | // These errors should not stop the execution
366 | this.utils.log.verbose(`Could not send telemetry: ${err.message}`);
367 | }
368 | try {
369 | client.close();
370 | } catch (e) {
371 | // These errors should not stop the execution
372 | }
373 | });
374 | }
375 |
376 | /**
377 | * @param {string} text
378 | * @returns {string}
379 | */
380 | gray(text) {
381 | const grayText = "\x1b[90m";
382 | const reset = "\x1b[0m";
383 | return `${grayText}${text}${reset}`;
384 | }
385 | }
386 |
387 | module.exports = ServerlessPlugin;
388 |
--------------------------------------------------------------------------------
/src/Runtime/LambdaRuntime.php:
--------------------------------------------------------------------------------
1 | processNextEvent(function ($event) {
31 | * return ;
32 | * });
33 | *
34 | * @internal
35 | */
36 | final class LambdaRuntime
37 | {
38 | /** @var resource|CurlHandle|null */
39 | private $curlHandleNext;
40 | /** @var resource|CurlHandle|null */
41 | private $curlHandleResult;
42 | private string $apiUrl;
43 | private Invoker $invoker;
44 | private string $layer;
45 |
46 | public static function fromEnvironmentVariable(string $layer): self
47 | {
48 | return new self((string) getenv('AWS_LAMBDA_RUNTIME_API'), $layer);
49 | }
50 |
51 | public function __construct(string $apiUrl, string $layer)
52 | {
53 | if ($apiUrl === '') {
54 | die('At the moment lambdas can only be executed in an Lambda environment');
55 | }
56 |
57 | $this->apiUrl = $apiUrl;
58 | $this->invoker = new Invoker;
59 | $this->layer = $layer;
60 | }
61 |
62 | public function __destruct()
63 | {
64 | $this->closeCurlHandleNext();
65 | $this->closeCurlHandleResult();
66 | }
67 |
68 | /**
69 | * Process the next event.
70 | *
71 | * @param Handler|RequestHandlerInterface|callable $handler If it is a callable, it takes two parameters, an $event parameter (mixed) and a $context parameter (Context) and must return anything serializable to JSON.
72 | *
73 | * Example:
74 | *
75 | * $lambdaRuntime->processNextEvent(function ($event, Context $context) {
76 | * return 'Hello ' . $event['name'] . '. We have ' . $context->getRemainingTimeInMillis()/1000 . ' seconds left';
77 | * });
78 | * @return bool true if event was successfully handled
79 | * @throws Exception
80 | */
81 | public function processNextEvent(Handler | RequestHandlerInterface | callable $handler): bool
82 | {
83 | [$event, $context] = $this->waitNextInvocation();
84 |
85 | // Expose the context in an environment variable
86 | $this->setEnv('LAMBDA_INVOCATION_CONTEXT', json_encode($context, JSON_THROW_ON_ERROR));
87 |
88 | try {
89 | ColdStartTracker::invocationStarted();
90 |
91 | Bref::triggerHooks('beforeInvoke');
92 | Bref::events()->beforeInvoke($handler, $event, $context);
93 |
94 | $this->ping();
95 |
96 | $result = $this->invoker->invoke($handler, $event, $context);
97 |
98 | $this->sendResponse($context->getAwsRequestId(), $result);
99 | } catch (Throwable $e) {
100 | $this->signalFailure($context->getAwsRequestId(), $e);
101 |
102 | try {
103 | Bref::events()->afterInvoke($handler, $event, $context, null, $e);
104 | } catch (Throwable $e) {
105 | $this->logError($e, $context->getAwsRequestId());
106 | }
107 |
108 | return false;
109 | }
110 |
111 | // Any error in the afterInvoke hook happens after the response has been sent,
112 | // we can no longer mark the invocation as failed. Instead we log the error.
113 | try {
114 | Bref::events()->afterInvoke($handler, $event, $context, $result);
115 | } catch (Throwable $e) {
116 | $this->logError($e, $context->getAwsRequestId());
117 |
118 | return false;
119 | }
120 |
121 | return true;
122 | }
123 |
124 | /**
125 | * Wait for the next lambda invocation and retrieve its data.
126 | *
127 | * This call is blocking because the Lambda runtime API is blocking.
128 | *
129 | * @return array{0: mixed, 1: Context}
130 | *
131 | * @see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-next
132 | */
133 | private function waitNextInvocation(): array
134 | {
135 | if ($this->curlHandleNext === null) {
136 | $this->curlHandleNext = curl_init("http://$this->apiUrl/2018-06-01/runtime/invocation/next");
137 | curl_setopt($this->curlHandleNext, CURLOPT_FOLLOWLOCATION, true);
138 | curl_setopt($this->curlHandleNext, CURLOPT_FAILONERROR, true);
139 | // Set a custom user agent so that AWS can estimate Bref usage in custom runtimes
140 | $phpVersion = substr(PHP_VERSION, 0, strpos(PHP_VERSION, '.', 2));
141 | curl_setopt($this->curlHandleNext, CURLOPT_USERAGENT, "bref/$this->layer/$phpVersion");
142 | }
143 |
144 | // Retrieve invocation ID
145 | $contextBuilder = new ContextBuilder;
146 | curl_setopt($this->curlHandleNext, CURLOPT_HEADERFUNCTION, function ($ch, $header) use ($contextBuilder) {
147 | if (! preg_match('/:\s*/', $header)) {
148 | return strlen($header);
149 | }
150 | [$name, $value] = preg_split('/:\s*/', $header, 2);
151 | $name = strtolower($name);
152 | $value = trim($value);
153 | if ($name === 'lambda-runtime-aws-request-id') {
154 | $contextBuilder->setAwsRequestId($value);
155 | }
156 | if ($name === 'lambda-runtime-deadline-ms') {
157 | $contextBuilder->setDeadlineMs(intval($value));
158 | }
159 | if ($name === 'lambda-runtime-invoked-function-arn') {
160 | $contextBuilder->setInvokedFunctionArn($value);
161 | }
162 | if ($name === 'lambda-runtime-trace-id') {
163 | $contextBuilder->setTraceId($value);
164 | }
165 |
166 | return strlen($header);
167 | });
168 |
169 | // Retrieve body
170 | $body = '';
171 | curl_setopt($this->curlHandleNext, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$body) {
172 | $body .= $chunk;
173 |
174 | return strlen($chunk);
175 | });
176 |
177 | curl_exec($this->curlHandleNext);
178 | if (curl_errno($this->curlHandleNext) > 0) {
179 | $message = curl_error($this->curlHandleNext);
180 | $this->closeCurlHandleNext();
181 | throw new Exception('Failed to fetch next Lambda invocation: ' . $message);
182 | }
183 | if ($body === '') {
184 | throw new Exception('Empty Lambda runtime API response');
185 | }
186 |
187 | $context = $contextBuilder->buildContext();
188 |
189 | if ($context->getAwsRequestId() === '') {
190 | throw new Exception('Failed to determine the Lambda invocation ID');
191 | }
192 |
193 | $event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
194 |
195 | return [$event, $context];
196 | }
197 |
198 | /**
199 | * @see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-response
200 | */
201 | private function sendResponse(string $invocationId, mixed $responseData): void
202 | {
203 | $url = "http://$this->apiUrl/2018-06-01/runtime/invocation/$invocationId/response";
204 | $this->postJson($url, $responseData);
205 | }
206 |
207 | /**
208 | * @see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-invokeerror
209 | */
210 | private function signalFailure(string $invocationId, Throwable $error): void
211 | {
212 | $this->logError($error, $invocationId);
213 |
214 | /**
215 | * Send an "error" Lambda response (see https://github.com/brefphp/bref/pull/1483).
216 | *
217 | * Unless the error was ResponseTooBig, in that case we would get the following error:
218 | *
219 | * InvalidStateTransition: State transition from RuntimeResponseSentState to InvocationErrorResponse failed for runtime. Error: State transition is not allowed
220 | *
221 | * It seems like once the response is sent, we can't signal an execution failure.
222 | * This is the same behavior in other runtimes like Node (the execution is successful despite the error).
223 | */
224 | if (! $error instanceof ResponseTooBig) {
225 | $url = "http://$this->apiUrl/2018-06-01/runtime/invocation/$invocationId/error";
226 | $this->postJson($url, [
227 | 'errorType' => get_class($error),
228 | 'errorMessage' => $error->getMessage(),
229 | 'stackTrace' => explode(PHP_EOL, $error->getTraceAsString()),
230 | ]);
231 | }
232 | }
233 |
234 | /**
235 | * Abort the lambda and signal to the runtime API that we failed to initialize this instance.
236 | *
237 | * @see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-initerror
238 | *
239 | * @phpstan-param 'Runtime.NoSuchHandler'|'Runtime.UnknownReason' $lambdaInitializationReason
240 | * @phpstan-return never-returns
241 | */
242 | public function failInitialization(
243 | string|Throwable $error,
244 | string $lambdaInitializationReason = 'Runtime.UnknownReason',
245 | ): void {
246 | // Log the exception in CloudWatch
247 | if ($error instanceof Throwable) {
248 | $traceAsArray = explode(PHP_EOL, $error->getTraceAsString());
249 | $data = [
250 | 'errorMessage' => $error->getMessage(),
251 | 'errorType' => get_class($error),
252 | 'stackTrace' => $traceAsArray,
253 | ];
254 | printf(
255 | "Fatal error: %s in %s:%d\n %s\n",
256 | get_class($error) . ': ' . $error->getMessage(),
257 | $error->getFile(),
258 | $error->getLine(),
259 | json_encode([
260 | 'message' => $error->getMessage(),
261 | 'type' => get_class($error),
262 | 'stackTrace' => $traceAsArray,
263 | ], JSON_THROW_ON_ERROR),
264 | );
265 | } else {
266 | $data = [
267 | 'errorMessage' => $error,
268 | 'errorType' => 'Internal',
269 | 'stackTrace' => [],
270 | ];
271 | echo "Fatal error: $error\n";
272 | }
273 |
274 | echo "The function failed to start. AWS Lambda will restart the process, do not be surprised if you see the error message twice.\n";
275 |
276 | $url = "http://$this->apiUrl/2018-06-01/runtime/init/error";
277 | $this->postJson($url, $data, [
278 | "Lambda-Runtime-Function-Error-Type: $lambdaInitializationReason",
279 | ]);
280 |
281 | exit(1);
282 | }
283 |
284 | /**
285 | * @param string[] $headers
286 | * @throws Exception
287 | * @throws ResponseTooBig
288 | */
289 | private function postJson(string $url, mixed $data, array $headers = []): void
290 | {
291 | /** @noinspection JsonEncodingApiUsageInspection */
292 | $jsonData = json_encode($data);
293 | if ($jsonData === false) {
294 | throw new Exception(sprintf(
295 | "The Lambda response cannot be encoded to JSON.\nThis error usually happens when you try to return binary content. If you are writing an HTTP application and you want to return a binary HTTP response (like an image, a PDF, etc.), please read this guide: https://bref.sh/docs/runtimes/http.html#binary-requests-and-responses\nHere is the original JSON error: '%s'",
296 | json_last_error_msg()
297 | ));
298 | }
299 |
300 | if ($this->curlHandleResult === null) {
301 | $this->curlHandleResult = curl_init();
302 | curl_setopt($this->curlHandleResult, CURLOPT_CUSTOMREQUEST, 'POST');
303 | curl_setopt($this->curlHandleResult, CURLOPT_RETURNTRANSFER, true);
304 | }
305 |
306 | curl_setopt($this->curlHandleResult, CURLOPT_URL, $url);
307 | curl_setopt($this->curlHandleResult, CURLOPT_POSTFIELDS, $jsonData);
308 | curl_setopt($this->curlHandleResult, CURLOPT_HTTPHEADER, [
309 | 'Content-Type: application/json',
310 | 'Content-Length: ' . strlen($jsonData),
311 | ...$headers,
312 | ]);
313 |
314 | $body = curl_exec($this->curlHandleResult);
315 |
316 | $statusCode = curl_getinfo($this->curlHandleResult, CURLINFO_HTTP_CODE);
317 | if ($statusCode >= 400) {
318 | // Re-open the connection in case of failure to start from a clean state
319 | $this->closeCurlHandleResult();
320 |
321 | if ($statusCode === 413) {
322 | throw new ResponseTooBig;
323 | }
324 |
325 | try {
326 | $error = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
327 | $errorMessage = "{$error['errorType']}: {$error['errorMessage']}";
328 | } catch (JsonException) {
329 | // In case we didn't get any JSON
330 | $errorMessage = 'unknown error';
331 | }
332 |
333 | throw new Exception("Error $statusCode while calling the Lambda runtime API: $errorMessage");
334 | }
335 | }
336 |
337 | private function closeCurlHandleNext(): void
338 | {
339 | if ($this->curlHandleNext !== null) {
340 | curl_close($this->curlHandleNext);
341 | $this->curlHandleNext = null;
342 | }
343 | }
344 |
345 | private function closeCurlHandleResult(): void
346 | {
347 | if ($this->curlHandleResult !== null) {
348 | curl_close($this->curlHandleResult);
349 | $this->curlHandleResult = null;
350 | }
351 | }
352 |
353 | /**
354 | * Ping a Bref server with a statsd request.
355 | *
356 | * WHY?
357 | * This ping is used to estimate the number of Bref invocations running in production.
358 | * Such statistic can be useful in many ways:
359 | * - so that potential Bref users know how much it is used in production
360 | * - to communicate to AWS how much Bref is used, and help them consider PHP as a native runtime
361 | *
362 | * WHAT?
363 | * The data sent in the ping is anonymous.
364 | * It does not contain any identifiable data about anything (the project, users, etc.).
365 | * The only data it contains is: "A Bref invocation happened using a specific layer".
366 | * You can verify that by checking the content of the message in the function.
367 | *
368 | * HOW?
369 | * The data is sent via the statsd protocol, over UDP.
370 | * Unlike TCP, UDP does not check that the message correctly arrived to the server.
371 | * It doesn't even establish a connection. That means that UDP is extremely fast:
372 | * the data is sent over the network and the code moves on to the next line.
373 | * When actually sending data, the overhead of that ping takes about 150 micro-seconds.
374 | * However, this function actually sends data every 100 invocation, because we don't
375 | * need to measure *all* invocations. We only need an approximation.
376 | * That means that 99% of the time, no data is sent, and the function takes 30 micro-seconds.
377 | * If we average all executions, the overhead of that ping is about 31 micro-seconds.
378 | * Given that it is much much less than even 1 millisecond, we consider that overhead
379 | * negligible.
380 | *
381 | * CAN I DISABLE IT?
382 | * Yes, set the `BREF_PING_DISABLE` environment variable to `1`.
383 | *
384 | * About the statsd server and protocol: https://github.com/statsd/statsd
385 | * About UDP: https://en.wikipedia.org/wiki/User_Datagram_Protocol
386 | */
387 | private function ping(): void
388 | {
389 | if ($_SERVER['BREF_PING_DISABLE'] ?? false) {
390 | return;
391 | }
392 |
393 | // Support cases where the sockets extension is not installed
394 | if (! function_exists('socket_create')) {
395 | return;
396 | }
397 |
398 | // Only run the code in 1% of requests
399 | // We don't need to collect all invocations, only to get an approximation
400 | /** @noinspection RandomApiMigrationInspection */
401 | if (rand(0, 99) > 0) {
402 | return;
403 | }
404 |
405 | $isColdStart = ColdStartTracker::currentInvocationIsUserFacingColdStart() ? '1' : '0';
406 | $isWarmInvocation = $isColdStart === '0' ? '1' : '0';
407 |
408 | /**
409 | * Here is the content sent to the Bref analytics server.
410 | * It signals an invocation happened on which layer and whether it was a cold start.
411 | * Nothing else is sent.
412 | *
413 | * `Invocations_100` is used to signal that 1 ping equals 100 invocations.
414 | * We could use statsd sample rate system like this:
415 | * `Invocations:1|c|@0.01`
416 | * but this doesn't seem to be compatible with the bridge that forwards
417 | * the metric into CloudWatch.
418 | *
419 | * See https://github.com/statsd/statsd/blob/master/docs/metric_types.md for more information.
420 | */
421 | $message = "Invocations_100:1|c\nLayer_{$this->layer}_100:1|c\nCold_100:$isColdStart|c\nWarm_100:$isWarmInvocation|c";
422 |
423 | $sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
424 | // This IP address is the Bref server.
425 | // If this server is down or unreachable, there should be no difference in overhead
426 | // or execution time.
427 | socket_sendto($sock, $message, strlen($message), 0, '3.219.198.164', 8125);
428 | socket_close($sock);
429 | }
430 |
431 | private function setEnv(string $name, string $value): void
432 | {
433 | $_SERVER[$name] = $_ENV[$name] = $value;
434 | if (! putenv("$name=$value")) {
435 | throw new RuntimeException("Failed to set environment variable $name");
436 | }
437 | }
438 |
439 | /**
440 | * Log the exception in CloudWatch
441 | * We aim to use the same log format as what we can see when throwing an exception in the NodeJS runtime
442 | *
443 | * @see https://github.com/brefphp/bref/pull/579
444 | */
445 | private function logError(Throwable $error, string $invocationId): void
446 | {
447 | $stackTraceAsArray = explode(PHP_EOL, $error->getTraceAsString());
448 | $errorFormatted = [
449 | 'errorType' => get_class($error),
450 | 'errorMessage' => $error->getMessage(),
451 | 'stack' => $stackTraceAsArray,
452 | ];
453 |
454 | if ($error->getPrevious() !== null) {
455 | $previousError = $error;
456 | $previousErrors = [];
457 | do {
458 | $previousError = $previousError->getPrevious();
459 | $previousErrors[] = [
460 | 'errorType' => get_class($previousError),
461 | 'errorMessage' => $previousError->getMessage(),
462 | 'stack' => explode(PHP_EOL, $previousError->getTraceAsString()),
463 | ];
464 | } while ($previousError->getPrevious() !== null);
465 |
466 | $errorFormatted['previous'] = $previousErrors;
467 | }
468 |
469 | /** @noinspection JsonEncodingApiUsageInspection */
470 | echo $invocationId . "\tInvoke Error\t" . json_encode($errorFormatted) . PHP_EOL;
471 | }
472 | }
473 |
--------------------------------------------------------------------------------