├── 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 |
21 |
22 | 23 |
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 | --------------------------------------------------------------------------------