From d2258d411a797c04b66f28e8a398ac3750c6a2d0 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 17:34:50 +0100 Subject: [PATCH 01/18] chore: add streamed http response support --- src/Event/Http/HttpHandler.php | 4 +- src/Event/Http/StreamedHttpResponse.php | 121 ++++++++++++++++++++++++ src/Runtime/LambdaRuntime.php | 71 +++++++++++++- 3 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/Event/Http/StreamedHttpResponse.php diff --git a/src/Event/Http/HttpHandler.php b/src/Event/Http/HttpHandler.php index ebd7092d5..c417c1cfb 100644 --- a/src/Event/Http/HttpHandler.php +++ b/src/Event/Http/HttpHandler.php @@ -7,10 +7,10 @@ abstract class HttpHandler implements Handler { - abstract public function handleRequest(HttpRequestEvent $event, Context $context): HttpResponse; + abstract public function handleRequest(HttpRequestEvent $event, Context $context): HttpResponse|StreamedHttpResponse; /** {@inheritDoc} */ - public function handle($event, Context $context): array + public function handle($event, Context $context): array|\Generator { // See https://bref.sh/docs/runtimes/http.html#cold-starts if (isset($event['warmer']) && $event['warmer'] === true) { diff --git a/src/Event/Http/StreamedHttpResponse.php b/src/Event/Http/StreamedHttpResponse.php new file mode 100644 index 000000000..b9debece3 --- /dev/null +++ b/src/Event/Http/StreamedHttpResponse.php @@ -0,0 +1,121 @@ + $headers + */ + public function __construct(Generator $body, array $headers = [], int $statusCode = 200) + { + $this->body = $body; + $this->headers = $headers; + $this->statusCode = $statusCode; + } + + public function toApiGatewayFormat(bool $multiHeaders = false): Generator + { + $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); + + $headers = []; + foreach ($this->headers as $name => $values) { + $name = $this->capitalizeHeaderName($name); + + if ($multiHeaders) { + // Make sure the values are always arrays + $headers[$name] = is_array($values) ? $values : [$values]; + } else { + // Make sure the values are never arrays + $headers[$name] = is_array($values) ? end($values) : $values; + } + } + + // The headers must be a JSON object. If the PHP array is empty it is + // serialized to `[]` (we want `{}`) so we force it to an empty object. + $headers = empty($headers) ? new \stdClass : $headers; + + // Support for multi-value headers (only in version 1.0 of the http payload) + $headersKey = $multiHeaders ? 'multiValueHeaders' : 'headers'; + + // This is the format required by the AWS_PROXY lambda integration + // See https://stackoverflow.com/questions/43708017/aws-lambda-api-gateway-error-malformed-lambda-proxy-response + yield json_encode([ + 'isBase64Encoded' => $base64Encoding, + 'statusCode' => $this->statusCode, + $headersKey => $headers, + ]); + + yield "\0\0\0\0\0\0\0\0"; + + foreach ($this->body as $dataChunk) { + $dataChunk = $base64Encoding ? base64_encode($dataChunk) : $dataChunk; + + yield dechex(strlen($dataChunk)) . "\r\n" . $dataChunk . "\r\n"; + } + + yield "0\r\n\r\n"; + } + + /** + * See https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response + */ + public function toApiGatewayFormatV2(): Generator + { + $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); + + $headers = []; + $cookies = []; + foreach ($this->headers as $name => $values) { + $name = $this->capitalizeHeaderName($name); + + if ($name === 'Set-Cookie') { + $cookies = is_array($values) ? $values : [$values]; + } else { + // Make sure the values are never arrays + // because API Gateway v2 does not support multi-value headers + $headers[$name] = is_array($values) ? implode(', ', $values) : $values; + } + } + + // The headers must be a JSON object. If the PHP array is empty it is + // serialized to `[]` (we want `{}`) so we force it to an empty object. + $headers = empty($headers) ? new \stdClass : $headers; + + yield json_encode([ + 'cookies' => $cookies, + 'isBase64Encoded' => $base64Encoding, + 'statusCode' => $this->statusCode, + 'headers' => $headers, + ]); + + yield "\0\0\0\0\0\0\0\0"; + + foreach ($this->body as $dataChunk) { + $dataChunk = $base64Encoding ? base64_encode($dataChunk) : $dataChunk; + + yield dechex(strlen($dataChunk)) . "\r\n" . $dataChunk . "\r\n"; + } + + yield "0\r\n\r\n"; + } + + /** + * See https://github.com/zendframework/zend-diactoros/blob/754a2ceb7ab753aafe6e3a70a1fb0370bde8995c/src/Response/SapiEmitterTrait.php#L96 + */ + private function capitalizeHeaderName(string $name): string + { + $name = str_replace('-', ' ', $name); + $name = ucwords($name); + return str_replace(' ', '-', $name); + } +} diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 17fa52e65..b0016c88c 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -8,6 +8,7 @@ use Bref\Event\Handler; use CurlHandle; use Exception; +use Generator; use JsonException; use Psr\Http\Server\RequestHandlerInterface; use RuntimeException; @@ -39,6 +40,8 @@ final class LambdaRuntime private $curlHandleNext; /** @var resource|CurlHandle|null */ private $curlHandleResult; + /** @var resource|CurlHandle|null */ + private $curlStreamedHandleResult; private string $apiUrl; private Invoker $invoker; private string $layer; @@ -201,7 +204,12 @@ private function waitNextInvocation(): array private function sendResponse(string $invocationId, mixed $responseData): void { $url = "http://$this->apiUrl/2018-06-01/runtime/invocation/$invocationId/response"; - $this->postJson($url, $responseData); + + if ($responseData instanceof Generator) { + $this->postStreamed($url, $responseData); + } else { + $this->postJson($url, $responseData); + } } /** @@ -281,6 +289,59 @@ public function failInitialization( exit(1); } + /** + * @param string[] $headers + * @throws Exception + * @throws ResponseTooBig + */ + private function postStreamed(string $url, Generator $data, array $headers = []): void + { + if ($this->curlStreamedHandleResult === null) { + $this->curlStreamedHandleResult = curl_init(); + curl_setopt($this->curlStreamedHandleResult, CURLOPT_CUSTOMREQUEST, 'POST'); + } + + curl_setopt($this->curlStreamedHandleResult, CURLOPT_URL, $url); + curl_setopt($this->curlStreamedHandleResult, CURLOPT_HTTPHEADER, [ + 'Lambda-Runtime-Function-Response-Mode: streaming', + 'Content-Type: application/vnd.awslambda.http-integration-response', + 'Transfer-Encoding: chunked', + ...$headers, + ]); + + curl_setopt($this->curlStreamedHandleResult, CURLOPT_WRITEFUNCTION, function () use (&$data) { + if ($data->valid()) { + $chunk = $data->current(); + $data->next(); + + // Return the chunk to be written to the stream. + return $chunk; + } + + // Return an empty string when the generator is exhausted. + return ''; + }); + + curl_setopt($this->curlStreamedHandleResult, CURLOPT_READFUNCTION, function () { + // We just need this to be a valid callback. The real work is in WRITEFUNCTION. + return ''; + }); + + $hasCompleted = curl_exec($this->curlStreamedHandleResult); + + $statusCode = curl_getinfo($this->curlStreamedHandleResult, CURLINFO_HTTP_CODE); + if ($statusCode >= 400 || $hasCompleted === false) { + // Re-open the connection in case of failure to start from a clean state + $this->closeCurlStreamedHandleResult(); + + if ($statusCode === 413) { + throw new ResponseTooBig; + } + + throw new Exception("Error $statusCode while calling the Lambda runtime API: unknown error"); + } + } + /** * @param string[] $headers * @throws Exception @@ -350,6 +411,14 @@ private function closeCurlHandleResult(): void } } + private function closeCurlStreamedHandleResult(): void + { + if ($this->curlStreamedHandleResult !== null) { + curl_close($this->curlStreamedHandleResult); + $this->curlStreamedHandleResult = null; + } + } + /** * Ping a Bref server with a statsd request. * From ca00f3d98ac90c7a5fc51ed682af4931a1ecb372 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 18:38:26 +0100 Subject: [PATCH 02/18] chore: add new boolean as invokeMode requires the response to also be streamed --- src/Event/Http/HttpResponse.php | 58 ++++++++++++++++++------- src/Event/Http/StreamedHttpResponse.php | 8 +--- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/Event/Http/HttpResponse.php b/src/Event/Http/HttpResponse.php index 80a679bc2..f572eb477 100644 --- a/src/Event/Http/HttpResponse.php +++ b/src/Event/Http/HttpResponse.php @@ -21,8 +21,9 @@ public function __construct(string $body, array $headers = [], int $statusCode = $this->statusCode = $statusCode; } - public function toApiGatewayFormat(bool $multiHeaders = false): array + public function toApiGatewayFormat(bool $multiHeaders = false): array|\Generator { + $isStreamedMode = (bool) getenv('BREF_STREAMED_MODE'); $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); $headers = []; @@ -47,19 +48,33 @@ public function toApiGatewayFormat(bool $multiHeaders = false): array // This is the format required by the AWS_PROXY lambda integration // See https://stackoverflow.com/questions/43708017/aws-lambda-api-gateway-error-malformed-lambda-proxy-response - return [ - 'isBase64Encoded' => $base64Encoding, - 'statusCode' => $this->statusCode, - $headersKey => $headers, - 'body' => $base64Encoding ? base64_encode($this->body) : $this->body, - ]; + + if ($isStreamedMode) { + yield json_encode([ + 'isBase64Encoded' => $base64Encoding, + 'statusCode' => $this->statusCode, + $headersKey => $headers, + ]); + + yield "\0\0\0\0\0\0\0\0"; + + yield $base64Encoding ? base64_encode($this->body) : $this->body; + } else { + return [ + 'isBase64Encoded' => $base64Encoding, + 'statusCode' => $this->statusCode, + $headersKey => $headers, + 'body' => $base64Encoding ? base64_encode($this->body) : $this->body, + ]; + } } /** * See https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response */ - public function toApiGatewayFormatV2(): array + public function toApiGatewayFormatV2(): array|\Generator { + $isStreamedMode = (bool) getenv('BREF_STREAMED_MODE'); $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); $headers = []; @@ -80,13 +95,26 @@ public function toApiGatewayFormatV2(): array // serialized to `[]` (we want `{}`) so we force it to an empty object. $headers = empty($headers) ? new \stdClass : $headers; - return [ - 'cookies' => $cookies, - 'isBase64Encoded' => $base64Encoding, - 'statusCode' => $this->statusCode, - 'headers' => $headers, - 'body' => $base64Encoding ? base64_encode($this->body) : $this->body, - ]; + if ($isStreamedMode) { + yield json_encode([ + 'cookies' => $cookies, + 'isBase64Encoded' => $base64Encoding, + 'statusCode' => $this->statusCode, + 'headers' => $headers, + ]); + + yield "\0\0\0\0\0\0\0\0"; + + yield $base64Encoding ? base64_encode($this->body) : $this->body; + } else { + return [ + 'cookies' => $cookies, + 'isBase64Encoded' => $base64Encoding, + 'statusCode' => $this->statusCode, + 'headers' => $headers, + 'body' => $base64Encoding ? base64_encode($this->body) : $this->body, + ]; + } } /** diff --git a/src/Event/Http/StreamedHttpResponse.php b/src/Event/Http/StreamedHttpResponse.php index b9debece3..434a5804d 100644 --- a/src/Event/Http/StreamedHttpResponse.php +++ b/src/Event/Http/StreamedHttpResponse.php @@ -60,10 +60,8 @@ public function toApiGatewayFormat(bool $multiHeaders = false): Generator foreach ($this->body as $dataChunk) { $dataChunk = $base64Encoding ? base64_encode($dataChunk) : $dataChunk; - yield dechex(strlen($dataChunk)) . "\r\n" . $dataChunk . "\r\n"; + yield $dataChunk; } - - yield "0\r\n\r\n"; } /** @@ -103,10 +101,8 @@ public function toApiGatewayFormatV2(): Generator foreach ($this->body as $dataChunk) { $dataChunk = $base64Encoding ? base64_encode($dataChunk) : $dataChunk; - yield dechex(strlen($dataChunk)) . "\r\n" . $dataChunk . "\r\n"; + yield $dataChunk; } - - yield "0\r\n\r\n"; } /** From e9be6e3f2e5a3bf86a2a13e543ac5d961ef9ceb8 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 18:59:05 +0100 Subject: [PATCH 03/18] chore: add logerror to api response --- src/Runtime/LambdaRuntime.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index b0016c88c..595c7133c 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -100,6 +100,8 @@ public function processNextEvent(Handler | RequestHandlerInterface | callable $h $this->sendResponse($context->getAwsRequestId(), $result); } catch (Throwable $e) { + $this->logError($e, $context->getAwsRequestId()); + $this->signalFailure($context->getAwsRequestId(), $e); try { From 5be0f451749db038fb9e2d9c2220768769787472 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 20:15:23 +0100 Subject: [PATCH 04/18] chore: fix curl streamed request --- src/Runtime/LambdaRuntime.php | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 595c7133c..9d6da5757 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -300,7 +300,10 @@ private function postStreamed(string $url, Generator $data, array $headers = []) { if ($this->curlStreamedHandleResult === null) { $this->curlStreamedHandleResult = curl_init(); - curl_setopt($this->curlStreamedHandleResult, CURLOPT_CUSTOMREQUEST, 'POST'); + curl_setopt($this->curlStreamedHandleResult, CURLOPT_POST, true); + curl_setopt($this->curlStreamedHandleResult, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); + curl_setopt($this->curlHandleResult, CURLOPT_RETURNTRANSFER, true); + curl_setopt($this->curlHandleResult, CURLOPT_UPLOAD, true); } curl_setopt($this->curlStreamedHandleResult, CURLOPT_URL, $url); @@ -311,7 +314,7 @@ private function postStreamed(string $url, Generator $data, array $headers = []) ...$headers, ]); - curl_setopt($this->curlStreamedHandleResult, CURLOPT_WRITEFUNCTION, function () use (&$data) { + curl_setopt($this->curlStreamedHandleResult, CURLOPT_READFUNCTION, function () use (&$data) { if ($data->valid()) { $chunk = $data->current(); $data->next(); @@ -324,15 +327,10 @@ private function postStreamed(string $url, Generator $data, array $headers = []) return ''; }); - curl_setopt($this->curlStreamedHandleResult, CURLOPT_READFUNCTION, function () { - // We just need this to be a valid callback. The real work is in WRITEFUNCTION. - return ''; - }); - - $hasCompleted = curl_exec($this->curlStreamedHandleResult); + $body = curl_exec($this->curlStreamedHandleResult); $statusCode = curl_getinfo($this->curlStreamedHandleResult, CURLINFO_HTTP_CODE); - if ($statusCode >= 400 || $hasCompleted === false) { + if ($statusCode >= 400) { // Re-open the connection in case of failure to start from a clean state $this->closeCurlStreamedHandleResult(); @@ -340,7 +338,15 @@ private function postStreamed(string $url, Generator $data, array $headers = []) throw new ResponseTooBig; } - throw new Exception("Error $statusCode while calling the Lambda runtime API: unknown error"); + try { + $error = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + $errorMessage = "{$error['errorType']}: {$error['errorMessage']}"; + } catch (JsonException) { + // In case we didn't get any JSON + $errorMessage = 'unknown error'; + } + + throw new Exception("Error $statusCode while calling the Lambda runtime API: $errorMessage"); } } From de1125d774244ce8168246824f391f7aca0b62a0 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 20:44:34 +0100 Subject: [PATCH 05/18] chore: fix curl streamed request --- src/Runtime/LambdaRuntime.php | 36 ++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 9d6da5757..36fbd4bb8 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -314,18 +314,32 @@ private function postStreamed(string $url, Generator $data, array $headers = []) ...$headers, ]); - curl_setopt($this->curlStreamedHandleResult, CURLOPT_READFUNCTION, function () use (&$data) { - if ($data->valid()) { - $chunk = $data->current(); - $data->next(); - - // Return the chunk to be written to the stream. - return $chunk; + $dataBuffer = ''; + $contentPos = 0; + + curl_setopt( + $this->curlStreamedHandleResult, + CURLOPT_READFUNCTION, + function ($ch, $fp, $len) use (&$data, &$dataBuffer, &$contentPos) { + if (strlen($dataBuffer) >= $contentPos + $len) { + $buffer = substr($dataBuffer, $contentPos, $len); + $contentPos += $len; + + return $buffer; + } elseif ($data->valid()) { + $dataBuffer .= $data->current(); + $data->next(); + + $buffer = substr($dataBuffer, $contentPos, $len); + $contentPos += $len; + + return $buffer; + } + + // Return an empty string when the generator is exhausted. + return ''; } - - // Return an empty string when the generator is exhausted. - return ''; - }); + ); $body = curl_exec($this->curlStreamedHandleResult); From c3a3206ca0d0f84dad4f07f91dba362ea8b766c9 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 20:56:19 +0100 Subject: [PATCH 06/18] chore: fix curl streamed request --- src/Runtime/LambdaRuntime.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 36fbd4bb8..2a1a82f29 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -323,7 +323,7 @@ private function postStreamed(string $url, Generator $data, array $headers = []) function ($ch, $fp, $len) use (&$data, &$dataBuffer, &$contentPos) { if (strlen($dataBuffer) >= $contentPos + $len) { $buffer = substr($dataBuffer, $contentPos, $len); - $contentPos += $len; + $contentPos += strlen($buffer); return $buffer; } elseif ($data->valid()) { @@ -331,7 +331,7 @@ function ($ch, $fp, $len) use (&$data, &$dataBuffer, &$contentPos) { $data->next(); $buffer = substr($dataBuffer, $contentPos, $len); - $contentPos += $len; + $contentPos += strlen($buffer); return $buffer; } From e47b5d04d5462320981229125dfcd4512e96d502 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 21:11:54 +0100 Subject: [PATCH 07/18] chore: fix curl streamed request --- src/Runtime/LambdaRuntime.php | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 2a1a82f29..48777940e 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -302,8 +302,9 @@ private function postStreamed(string $url, Generator $data, array $headers = []) $this->curlStreamedHandleResult = curl_init(); curl_setopt($this->curlStreamedHandleResult, CURLOPT_POST, true); curl_setopt($this->curlStreamedHandleResult, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); - curl_setopt($this->curlHandleResult, CURLOPT_RETURNTRANSFER, true); - curl_setopt($this->curlHandleResult, CURLOPT_UPLOAD, true); + curl_setopt($this->curlStreamedHandleResult, CURLOPT_RETURNTRANSFER, true); + curl_setopt($this->curlStreamedHandleResult, CURLOPT_UPLOAD, true); + curl_setopt($this->curlStreamedHandleResult, CURLOPT_INFILESIZE, -1); } curl_setopt($this->curlStreamedHandleResult, CURLOPT_URL, $url); @@ -314,26 +315,15 @@ private function postStreamed(string $url, Generator $data, array $headers = []) ...$headers, ]); - $dataBuffer = ''; - $contentPos = 0; - curl_setopt( $this->curlStreamedHandleResult, CURLOPT_READFUNCTION, - function ($ch, $fp, $len) use (&$data, &$dataBuffer, &$contentPos) { - if (strlen($dataBuffer) >= $contentPos + $len) { - $buffer = substr($dataBuffer, $contentPos, $len); - $contentPos += strlen($buffer); - - return $buffer; - } elseif ($data->valid()) { - $dataBuffer .= $data->current(); + function () use (&$data) { + if ($data->valid()) { + $dataBuffer = $data->current(); $data->next(); - $buffer = substr($dataBuffer, $contentPos, $len); - $contentPos += strlen($buffer); - - return $buffer; + return $dataBuffer; } // Return an empty string when the generator is exhausted. From a8d8e9b712c9594c229ad29e9bc6765f8d653f57 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 21:21:17 +0100 Subject: [PATCH 08/18] chore: fix curl streamed request --- src/Runtime/LambdaRuntime.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 48777940e..61ccc86e9 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -300,10 +300,10 @@ private function postStreamed(string $url, Generator $data, array $headers = []) { if ($this->curlStreamedHandleResult === null) { $this->curlStreamedHandleResult = curl_init(); - curl_setopt($this->curlStreamedHandleResult, CURLOPT_POST, true); + curl_setopt($this->curlStreamedHandleResult, CURLOPT_UPLOAD, true); + curl_setopt($this->curlStreamedHandleResult, CURLOPT_CUSTOMREQUEST, 'POST'); curl_setopt($this->curlStreamedHandleResult, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); curl_setopt($this->curlStreamedHandleResult, CURLOPT_RETURNTRANSFER, true); - curl_setopt($this->curlStreamedHandleResult, CURLOPT_UPLOAD, true); curl_setopt($this->curlStreamedHandleResult, CURLOPT_INFILESIZE, -1); } @@ -319,6 +319,8 @@ private function postStreamed(string $url, Generator $data, array $headers = []) $this->curlStreamedHandleResult, CURLOPT_READFUNCTION, function () use (&$data) { + $this->logError(new \Exception("Reading chunk"), "Chunk..."); + if ($data->valid()) { $dataBuffer = $data->current(); $data->next(); @@ -347,7 +349,7 @@ function () use (&$data) { $errorMessage = "{$error['errorType']}: {$error['errorMessage']}"; } catch (JsonException) { // In case we didn't get any JSON - $errorMessage = 'unknown error'; + $errorMessage = 'unknown error: ' . $body; } throw new Exception("Error $statusCode while calling the Lambda runtime API: $errorMessage"); From 245b73a5aa53fac8a1f37f078fc2a6bb784573a2 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 21:31:41 +0100 Subject: [PATCH 09/18] chore: fix curl streamed request --- src/Runtime/LambdaRuntime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 61ccc86e9..5a638f725 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -319,7 +319,7 @@ private function postStreamed(string $url, Generator $data, array $headers = []) $this->curlStreamedHandleResult, CURLOPT_READFUNCTION, function () use (&$data) { - $this->logError(new \Exception("Reading chunk"), "Chunk..."); + $this->logError(new \Exception('Reading chunk'), 'Chunk...'); if ($data->valid()) { $dataBuffer = $data->current(); From 3ae6af5fbfbb2adf93fdb137ba662ca7156bd3d5 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 21:44:07 +0100 Subject: [PATCH 10/18] chore: base64 and isStreamedMode in Streamed event --- src/Event/Http/HttpResponse.php | 6 +- src/Event/Http/StreamedHttpResponse.php | 74 ++++++++++++++++++------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/Event/Http/HttpResponse.php b/src/Event/Http/HttpResponse.php index f572eb477..941f30835 100644 --- a/src/Event/Http/HttpResponse.php +++ b/src/Event/Http/HttpResponse.php @@ -51,14 +51,13 @@ public function toApiGatewayFormat(bool $multiHeaders = false): array|\Generator if ($isStreamedMode) { yield json_encode([ - 'isBase64Encoded' => $base64Encoding, 'statusCode' => $this->statusCode, $headersKey => $headers, ]); yield "\0\0\0\0\0\0\0\0"; - yield $base64Encoding ? base64_encode($this->body) : $this->body; + yield $this->body; } else { return [ 'isBase64Encoded' => $base64Encoding, @@ -98,14 +97,13 @@ public function toApiGatewayFormatV2(): array|\Generator if ($isStreamedMode) { yield json_encode([ 'cookies' => $cookies, - 'isBase64Encoded' => $base64Encoding, 'statusCode' => $this->statusCode, 'headers' => $headers, ]); yield "\0\0\0\0\0\0\0\0"; - yield $base64Encoding ? base64_encode($this->body) : $this->body; + yield $this->body; } else { return [ 'cookies' => $cookies, diff --git a/src/Event/Http/StreamedHttpResponse.php b/src/Event/Http/StreamedHttpResponse.php index 434a5804d..e04dafe96 100644 --- a/src/Event/Http/StreamedHttpResponse.php +++ b/src/Event/Http/StreamedHttpResponse.php @@ -23,8 +23,9 @@ public function __construct(Generator $body, array $headers = [], int $statusCod $this->statusCode = $statusCode; } - public function toApiGatewayFormat(bool $multiHeaders = false): Generator + public function toApiGatewayFormat(bool $multiHeaders = false): array|\Generator { + $isStreamedMode = (bool) getenv('BREF_STREAMED_MODE'); $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); $headers = []; @@ -49,26 +50,42 @@ public function toApiGatewayFormat(bool $multiHeaders = false): Generator // This is the format required by the AWS_PROXY lambda integration // See https://stackoverflow.com/questions/43708017/aws-lambda-api-gateway-error-malformed-lambda-proxy-response - yield json_encode([ - 'isBase64Encoded' => $base64Encoding, - 'statusCode' => $this->statusCode, - $headersKey => $headers, - ]); - yield "\0\0\0\0\0\0\0\0"; + if ($isStreamedMode) { + yield json_encode([ + 'statusCode' => $this->statusCode, + $headersKey => $headers, + ]); - foreach ($this->body as $dataChunk) { - $dataChunk = $base64Encoding ? base64_encode($dataChunk) : $dataChunk; + yield "\0\0\0\0\0\0\0\0"; - yield $dataChunk; + foreach ($this->body as $dataChunk) { + yield $dataChunk; + } + } else { + $dataChunk = ''; + + while ($this->body->valid()) { + $dataChunk .= $this->body->current(); + + $this->body->next(); + } + + return [ + 'isBase64Encoded' => $base64Encoding, + 'statusCode' => $this->statusCode, + $headersKey => $headers, + 'body' => $base64Encoding ? base64_encode($dataChunk) : $dataChunk, + ]; } } /** * See https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response */ - public function toApiGatewayFormatV2(): Generator + public function toApiGatewayFormatV2(): array|\Generator { + $isStreamedMode = (bool) getenv('BREF_STREAMED_MODE'); $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); $headers = []; @@ -89,19 +106,34 @@ public function toApiGatewayFormatV2(): Generator // serialized to `[]` (we want `{}`) so we force it to an empty object. $headers = empty($headers) ? new \stdClass : $headers; - yield json_encode([ - 'cookies' => $cookies, - 'isBase64Encoded' => $base64Encoding, - 'statusCode' => $this->statusCode, - 'headers' => $headers, - ]); + if ($isStreamedMode) { + yield json_encode([ + 'cookies' => $cookies, + 'statusCode' => $this->statusCode, + 'headers' => $headers, + ]); - yield "\0\0\0\0\0\0\0\0"; + yield "\0\0\0\0\0\0\0\0"; - foreach ($this->body as $dataChunk) { - $dataChunk = $base64Encoding ? base64_encode($dataChunk) : $dataChunk; + foreach ($this->body as $dataChunk) { + yield $dataChunk; + } + } else { + $dataChunk = ''; + + while ($this->body->valid()) { + $dataChunk .= $this->body->current(); + + $this->body->next(); + } - yield $dataChunk; + return [ + 'cookies' => $cookies, + 'isBase64Encoded' => $base64Encoding, + 'statusCode' => $this->statusCode, + 'headers' => $headers, + 'body' => $base64Encoding ? base64_encode($dataChunk) : $dataChunk, + ]; } } From 293abf9f057df27a4b313acfc3031df7a2bfd009 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 22:11:25 +0100 Subject: [PATCH 11/18] chore: stream respecting length from curl read function --- src/Runtime/LambdaRuntime.php | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 5a638f725..66e79b4e4 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -315,21 +315,20 @@ private function postStreamed(string $url, Generator $data, array $headers = []) ...$headers, ]); + $buffer = ''; curl_setopt( $this->curlStreamedHandleResult, CURLOPT_READFUNCTION, - function () use (&$data) { - $this->logError(new \Exception('Reading chunk'), 'Chunk...'); - - if ($data->valid()) { - $dataBuffer = $data->current(); + function ($ch, $fd, $length) use (&$data, &$buffer) { + while (strlen($buffer) < $length && $data->valid()) { + $buffer .= (string) $data->current(); $data->next(); - - return $dataBuffer; } - // Return an empty string when the generator is exhausted. - return ''; + $chunk = substr($buffer, 0, $length); + $buffer = substr($buffer, strlen($chunk)); + + return $chunk; } ); @@ -349,7 +348,7 @@ function () use (&$data) { $errorMessage = "{$error['errorType']}: {$error['errorMessage']}"; } catch (JsonException) { // In case we didn't get any JSON - $errorMessage = 'unknown error: ' . $body; + $errorMessage = 'unknown error'; } throw new Exception("Error $statusCode while calling the Lambda runtime API: $errorMessage"); From 8eb5956903a97fb136d44d0a93fae54af342bbc8 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 22:22:33 +0100 Subject: [PATCH 12/18] chore: stream should not accumulate if its data is lower than length --- src/Runtime/LambdaRuntime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 66e79b4e4..b1cb7bc0f 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -320,7 +320,7 @@ private function postStreamed(string $url, Generator $data, array $headers = []) $this->curlStreamedHandleResult, CURLOPT_READFUNCTION, function ($ch, $fd, $length) use (&$data, &$buffer) { - while (strlen($buffer) < $length && $data->valid()) { + if (strlen($buffer) < $length && $data->valid()) { $buffer .= (string) $data->current(); $data->next(); } From 27d7cc036759b6478f423516274b67e592f7e542 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 22:42:44 +0100 Subject: [PATCH 13/18] chore: stream using fiber --- src/Runtime/LambdaRuntime.php | 61 +++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index b1cb7bc0f..818848dc0 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -315,22 +315,55 @@ private function postStreamed(string $url, Generator $data, array $headers = []) ...$headers, ]); - $buffer = ''; - curl_setopt( - $this->curlStreamedHandleResult, - CURLOPT_READFUNCTION, - function ($ch, $fd, $length) use (&$data, &$buffer) { - if (strlen($buffer) < $length && $data->valid()) { - $buffer .= (string) $data->current(); - $data->next(); + if (PHP_VERSION_ID < 80100) { + $buffer = ''; + curl_setopt( + $this->curlStreamedHandleResult, + CURLOPT_READFUNCTION, + function ($ch, $fd, $length) use (&$data, &$buffer) { + if (strlen($buffer) < $length && $data->valid()) { + $buffer .= (string) $data->current(); + $data->next(); + } + + $chunk = substr($buffer, 0, $length); + $buffer = substr($buffer, strlen($chunk)); + + return $chunk; } + ); + } else { + $buffer = ''; + $fiber = new \Fiber( + function () use (&$data): void { + foreach ($data as $dataChunk) { + \Fiber::suspend((string) $dataChunk); + } + + \Fiber::suspend(PHP_INT_MIN); + } + ); - $chunk = substr($buffer, 0, $length); - $buffer = substr($buffer, strlen($chunk)); - - return $chunk; - } - ); + curl_setopt( + $this->curlStreamedHandleResult, + CURLOPT_READFUNCTION, + function ($ch, $fd, $length) use (&$fiber, &$buffer) { + if ($buffer === '') { + if ($fiber->isStarted() || $fiber->isSuspended()) { + $fiberChunk = $fiber->resume(); + if ($fiberChunk !== PHP_INT_MIN) { + $buffer .= $fiberChunk; + } + } + } + + $chunk = substr($buffer, 0, $length); + $buffer = substr($buffer, strlen($chunk)); + + return $chunk; + } + ); + } $body = curl_exec($this->curlStreamedHandleResult); From 4048e93906ebda069d4eb96cee85fccf22d00dbe Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 22:45:22 +0100 Subject: [PATCH 14/18] chore: explain fibers --- src/Runtime/LambdaRuntime.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 818848dc0..e4b3730fd 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -323,6 +323,11 @@ private function postStreamed(string $url, Generator $data, array $headers = []) function ($ch, $fd, $length) use (&$data, &$buffer) { if (strlen($buffer) < $length && $data->valid()) { $buffer .= (string) $data->current(); + + /* + As this method needs to return an string, we need to wait for the next generator item to yield. + This can lead to the initial part of the buffer taking longer to load if the next chunk takes longer. + */ $data->next(); } @@ -334,6 +339,10 @@ function ($ch, $fd, $length) use (&$data, &$buffer) { ); } else { $buffer = ''; + /* + * We use Fibers so we can suspend the yields and read data as needed. + * That way we don't block the response as more data comes. + */ $fiber = new \Fiber( function () use (&$data): void { foreach ($data as $dataChunk) { From c1eacdd46f497b211746dcea8a4077423923e759 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Sun, 20 Jul 2025 22:54:45 +0100 Subject: [PATCH 15/18] chore: fiber starts --- src/Runtime/LambdaRuntime.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index e4b3730fd..c51393fbc 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -360,9 +360,14 @@ function ($ch, $fd, $length) use (&$fiber, &$buffer) { if ($buffer === '') { if ($fiber->isStarted() || $fiber->isSuspended()) { $fiberChunk = $fiber->resume(); - if ($fiberChunk !== PHP_INT_MIN) { - $buffer .= $fiberChunk; - } + } elseif (! $fiber->isTerminated()) { + $fiberChunk = $fiber->start(); + } else { + $fiberChunk = PHP_INT_MIN; + } + + if ($fiberChunk !== PHP_INT_MIN) { + $buffer .= $fiberChunk; } } From a59846769fb46eb5a1c5195ff60e9a0a86938496 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Tue, 18 Aug 2026 15:48:48 +0100 Subject: [PATCH 16/18] chore: fix typo in \Generator --- src/Event/Http/HttpResponse.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Event/Http/HttpResponse.php b/src/Event/Http/HttpResponse.php index c0de6b2f1..c7d7feaac 100644 --- a/src/Event/Http/HttpResponse.php +++ b/src/Event/Http/HttpResponse.php @@ -21,7 +21,7 @@ public function __construct(string $body, array $headers = [], int $statusCode = $this->statusCode = $statusCode; } - public function toApiGatewayFormat(bool $multiHeaders = false, ?string $awsRequestId = null): array\Generator + public function toApiGatewayFormat(bool $multiHeaders = false, ?string $awsRequestId = null): array|\Generator { $isStreamedMode = (bool) getenv('BREF_STREAMED_MODE'); $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); From 49094635495d971915e5e53ef6c14170717ed8c6 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Tue, 18 Aug 2026 17:52:13 +0100 Subject: [PATCH 17/18] feat: merge StreamedHttpResponse into HttpResponse - HttpResponse now accepts string|\Generator bodies with yieldBody() - Auto-detect streaming runtime via Bref::isRunningInStreamingMode() - Delete StreamedHttpResponse and unify test coverage - Upgrade PHPUnit to ^10, fix .gitignore cache file name - Drop dead pre-PHP-8.1 curl readfunction and deprecated curl_close() --- .gitignore | 2 +- src/Bref.php | 5 + src/Event/Http/HttpHandler.php | 2 +- src/Event/Http/HttpResponse.php | 85 +++++--- src/Event/Http/StreamedHttpResponse.php | 149 -------------- src/Runtime/LambdaRuntime.php | 93 +++------ tests/Event/Http/HttpHandlerTest.php | 101 ++++++++++ tests/Event/Http/HttpResponseStreamedTest.php | 190 ++++++++++++++++++ tests/Event/Http/HttpResponseTest.php | 38 ++++ tests/Runtime/LambdaRuntimeTest.php | 35 ++++ 10 files changed, 463 insertions(+), 237 deletions(-) delete mode 100644 src/Event/Http/StreamedHttpResponse.php create mode 100644 tests/Event/Http/HttpHandlerTest.php create mode 100644 tests/Event/Http/HttpResponseStreamedTest.php diff --git a/.gitignore b/.gitignore index ecc72d731..9bc2c1efc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ /website/node_modules/ .DS_Store /.serverless/ -.phpunit.cache +.phpunit.result.cache node_modules/ package-lock.json /.claude diff --git a/src/Bref.php b/src/Bref.php index 38b705c52..f77d3d330 100644 --- a/src/Bref.php +++ b/src/Bref.php @@ -32,6 +32,11 @@ public static function events(): EventDispatcher return self::$eventDispatcher; } + public static function isRunningInStreamingMode(): bool + { + return (bool) getenv('BREF_STREAMED_MODE'); + } + /** * @internal Used by the Bref runtime */ diff --git a/src/Event/Http/HttpHandler.php b/src/Event/Http/HttpHandler.php index 2b85ec591..49734ac30 100644 --- a/src/Event/Http/HttpHandler.php +++ b/src/Event/Http/HttpHandler.php @@ -7,7 +7,7 @@ abstract class HttpHandler implements Handler { - abstract public function handleRequest(HttpRequestEvent $event, Context $context): HttpResponse|StreamedHttpResponse; + abstract public function handleRequest(HttpRequestEvent $event, Context $context): HttpResponse; /** {@inheritDoc} */ public function handle($event, Context $context): array|\Generator diff --git a/src/Event/Http/HttpResponse.php b/src/Event/Http/HttpResponse.php index c7d7feaac..6d1f27150 100644 --- a/src/Event/Http/HttpResponse.php +++ b/src/Event/Http/HttpResponse.php @@ -2,6 +2,9 @@ namespace Bref\Event\Http; +use Bref\Bref; +use Generator; + /** * Formats the response expected by AWS Lambda and the API Gateway integration. */ @@ -9,12 +12,12 @@ final class HttpResponse { private int $statusCode; private array $headers; - private string $body; + private string|\Generator $body; /** * @param array $headers */ - public function __construct(string $body, array $headers = [], int $statusCode = 200) + public function __construct(string|\Generator $body, array $headers = [], int $statusCode = 200) { $this->body = $body; $this->headers = $headers; @@ -23,7 +26,7 @@ public function __construct(string $body, array $headers = [], int $statusCode = public function toApiGatewayFormat(bool $multiHeaders = false, ?string $awsRequestId = null): array|\Generator { - $isStreamedMode = (bool) getenv('BREF_STREAMED_MODE'); + $isStreamedMode = Bref::isRunningInStreamingMode(); $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); $headers = []; @@ -52,22 +55,18 @@ public function toApiGatewayFormat(bool $multiHeaders = false, ?string $awsReque // See https://stackoverflow.com/questions/43708017/aws-lambda-api-gateway-error-malformed-lambda-proxy-response if ($isStreamedMode) { - yield json_encode([ + return $this->yieldBody([ 'statusCode' => $this->statusCode, $headersKey => $headers, ]); - - yield "\0\0\0\0\0\0\0\0"; - - yield $this->body; - } else { - return [ - 'isBase64Encoded' => $base64Encoding, - 'statusCode' => $this->statusCode, - $headersKey => $headers, - 'body' => $base64Encoding ? base64_encode($this->body) : $this->body, - ]; } + + return [ + 'isBase64Encoded' => $base64Encoding, + 'statusCode' => $this->statusCode, + $headersKey => $headers, + 'body' => $base64Encoding ? base64_encode($this->getBodyAsString()) : $this->getBodyAsString(), + ]; } /** @@ -75,7 +74,7 @@ public function toApiGatewayFormat(bool $multiHeaders = false, ?string $awsReque */ public function toApiGatewayFormatV2(?string $awsRequestId = null): array|\Generator { - $isStreamedMode = (bool) getenv('BREF_STREAMED_MODE'); + $isStreamedMode = Bref::isRunningInStreamingMode(); $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); $headers = []; @@ -102,26 +101,60 @@ public function toApiGatewayFormatV2(?string $awsRequestId = null): array|\Gener $headers = empty($headers) ? new \stdClass : $headers; if ($isStreamedMode) { - yield json_encode([ + return $this->yieldBody([ 'cookies' => $cookies, 'statusCode' => $this->statusCode, 'headers' => $headers, ]); + } - yield "\0\0\0\0\0\0\0\0"; + return [ + 'cookies' => $cookies, + 'isBase64Encoded' => $base64Encoding, + 'statusCode' => $this->statusCode, + 'headers' => $headers, + 'body' => $base64Encoding ? base64_encode($this->getBodyAsString()) : $this->getBodyAsString(), + ]; + } - yield $this->body; + /** + * Yields the metadata, the null-byte separator and the body chunks in the + * Lambda Streaming Format. + * + * @param array $metadata + */ + private function yieldBody(array $metadata): Generator + { + yield json_encode($metadata); + + yield "\0\0\0\0\0\0\0\0"; + + if ($this->body instanceof Generator) { + foreach ($this->body as $dataChunk) { + yield $dataChunk; + } } else { - return [ - 'cookies' => $cookies, - 'isBase64Encoded' => $base64Encoding, - 'statusCode' => $this->statusCode, - 'headers' => $headers, - 'body' => $base64Encoding ? base64_encode($this->body) : $this->body, - ]; + yield $this->body; } } + private function getBodyAsString(): string + { + if ($this->body instanceof Generator) { + $dataChunk = ''; + + while ($this->body->valid()) { + $dataChunk .= $this->body->current(); + + $this->body->next(); + } + + return $dataChunk; + } + + return $this->body; + } + /** * See https://github.com/zendframework/zend-diactoros/blob/754a2ceb7ab753aafe6e3a70a1fb0370bde8995c/src/Response/SapiEmitterTrait.php#L96 */ diff --git a/src/Event/Http/StreamedHttpResponse.php b/src/Event/Http/StreamedHttpResponse.php deleted file mode 100644 index e04dafe96..000000000 --- a/src/Event/Http/StreamedHttpResponse.php +++ /dev/null @@ -1,149 +0,0 @@ - $headers - */ - public function __construct(Generator $body, array $headers = [], int $statusCode = 200) - { - $this->body = $body; - $this->headers = $headers; - $this->statusCode = $statusCode; - } - - public function toApiGatewayFormat(bool $multiHeaders = false): array|\Generator - { - $isStreamedMode = (bool) getenv('BREF_STREAMED_MODE'); - $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); - - $headers = []; - foreach ($this->headers as $name => $values) { - $name = $this->capitalizeHeaderName($name); - - if ($multiHeaders) { - // Make sure the values are always arrays - $headers[$name] = is_array($values) ? $values : [$values]; - } else { - // Make sure the values are never arrays - $headers[$name] = is_array($values) ? end($values) : $values; - } - } - - // The headers must be a JSON object. If the PHP array is empty it is - // serialized to `[]` (we want `{}`) so we force it to an empty object. - $headers = empty($headers) ? new \stdClass : $headers; - - // Support for multi-value headers (only in version 1.0 of the http payload) - $headersKey = $multiHeaders ? 'multiValueHeaders' : 'headers'; - - // This is the format required by the AWS_PROXY lambda integration - // See https://stackoverflow.com/questions/43708017/aws-lambda-api-gateway-error-malformed-lambda-proxy-response - - if ($isStreamedMode) { - yield json_encode([ - 'statusCode' => $this->statusCode, - $headersKey => $headers, - ]); - - yield "\0\0\0\0\0\0\0\0"; - - foreach ($this->body as $dataChunk) { - yield $dataChunk; - } - } else { - $dataChunk = ''; - - while ($this->body->valid()) { - $dataChunk .= $this->body->current(); - - $this->body->next(); - } - - return [ - 'isBase64Encoded' => $base64Encoding, - 'statusCode' => $this->statusCode, - $headersKey => $headers, - 'body' => $base64Encoding ? base64_encode($dataChunk) : $dataChunk, - ]; - } - } - - /** - * See https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response - */ - public function toApiGatewayFormatV2(): array|\Generator - { - $isStreamedMode = (bool) getenv('BREF_STREAMED_MODE'); - $base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES'); - - $headers = []; - $cookies = []; - foreach ($this->headers as $name => $values) { - $name = $this->capitalizeHeaderName($name); - - if ($name === 'Set-Cookie') { - $cookies = is_array($values) ? $values : [$values]; - } else { - // Make sure the values are never arrays - // because API Gateway v2 does not support multi-value headers - $headers[$name] = is_array($values) ? implode(', ', $values) : $values; - } - } - - // The headers must be a JSON object. If the PHP array is empty it is - // serialized to `[]` (we want `{}`) so we force it to an empty object. - $headers = empty($headers) ? new \stdClass : $headers; - - if ($isStreamedMode) { - yield json_encode([ - 'cookies' => $cookies, - 'statusCode' => $this->statusCode, - 'headers' => $headers, - ]); - - yield "\0\0\0\0\0\0\0\0"; - - foreach ($this->body as $dataChunk) { - yield $dataChunk; - } - } else { - $dataChunk = ''; - - while ($this->body->valid()) { - $dataChunk .= $this->body->current(); - - $this->body->next(); - } - - return [ - 'cookies' => $cookies, - 'isBase64Encoded' => $base64Encoding, - 'statusCode' => $this->statusCode, - 'headers' => $headers, - 'body' => $base64Encoding ? base64_encode($dataChunk) : $dataChunk, - ]; - } - } - - /** - * See https://github.com/zendframework/zend-diactoros/blob/754a2ceb7ab753aafe6e3a70a1fb0370bde8995c/src/Response/SapiEmitterTrait.php#L96 - */ - private function capitalizeHeaderName(string $name): string - { - $name = str_replace('-', ' ', $name); - $name = ucwords($name); - return str_replace(' ', '-', $name); - } -} diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 6bbd03c07..546c30713 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -99,8 +99,6 @@ public function processNextEvent(Handler | RequestHandlerInterface | callable $h $this->sendResponse($context->getAwsRequestId(), $result); } catch (Throwable $e) { - $this->logError($e, $context->getAwsRequestId()); - $this->signalFailure($context->getAwsRequestId(), $e); try { @@ -314,69 +312,45 @@ private function postStreamed(string $url, Generator $data, array $headers = []) ...$headers, ]); - if (PHP_VERSION_ID < 80100) { - $buffer = ''; - curl_setopt( - $this->curlStreamedHandleResult, - CURLOPT_READFUNCTION, - function ($ch, $fd, $length) use (&$data, &$buffer) { - if (strlen($buffer) < $length && $data->valid()) { - $buffer .= (string) $data->current(); - - /* - As this method needs to return an string, we need to wait for the next generator item to yield. - This can lead to the initial part of the buffer taking longer to load if the next chunk takes longer. - */ - $data->next(); - } - - $chunk = substr($buffer, 0, $length); - $buffer = substr($buffer, strlen($chunk)); - - return $chunk; + $buffer = ''; + /* + * We use Fibers so we can suspend the yields and read data as needed. + * That way we don't block the response as more data comes. + */ + $fiber = new \Fiber( + function () use (&$data): void { + foreach ($data as $dataChunk) { + \Fiber::suspend((string) $dataChunk); } - ); - } else { - $buffer = ''; - /* - * We use Fibers so we can suspend the yields and read data as needed. - * That way we don't block the response as more data comes. - */ - $fiber = new \Fiber( - function () use (&$data): void { - foreach ($data as $dataChunk) { - \Fiber::suspend((string) $dataChunk); - } - \Fiber::suspend(PHP_INT_MIN); - } - ); + \Fiber::suspend(PHP_INT_MIN); + } + ); + + curl_setopt( + $this->curlStreamedHandleResult, + CURLOPT_READFUNCTION, + function ($ch, $fd, $length) use (&$fiber, &$buffer) { + if ($buffer === '') { + if ($fiber->isStarted() || $fiber->isSuspended()) { + $fiberChunk = $fiber->resume(); + } elseif (! $fiber->isTerminated()) { + $fiberChunk = $fiber->start(); + } else { + $fiberChunk = PHP_INT_MIN; + } - curl_setopt( - $this->curlStreamedHandleResult, - CURLOPT_READFUNCTION, - function ($ch, $fd, $length) use (&$fiber, &$buffer) { - if ($buffer === '') { - if ($fiber->isStarted() || $fiber->isSuspended()) { - $fiberChunk = $fiber->resume(); - } elseif (! $fiber->isTerminated()) { - $fiberChunk = $fiber->start(); - } else { - $fiberChunk = PHP_INT_MIN; - } - - if ($fiberChunk !== PHP_INT_MIN) { - $buffer .= $fiberChunk; - } + if ($fiberChunk !== PHP_INT_MIN) { + $buffer .= $fiberChunk; } + } - $chunk = substr($buffer, 0, $length); - $buffer = substr($buffer, strlen($chunk)); + $chunk = substr($buffer, 0, $length); + $buffer = substr($buffer, strlen($chunk)); - return $chunk; - } - ); - } + return $chunk; + } + ); $body = curl_exec($this->curlStreamedHandleResult); @@ -477,7 +451,6 @@ private function closeCurlHandleResult(): void private function closeCurlStreamedHandleResult(): void { if ($this->curlStreamedHandleResult !== null) { - curl_close($this->curlStreamedHandleResult); $this->curlStreamedHandleResult = null; } } diff --git a/tests/Event/Http/HttpHandlerTest.php b/tests/Event/Http/HttpHandlerTest.php new file mode 100644 index 000000000..20c686743 --- /dev/null +++ b/tests/Event/Http/HttpHandlerTest.php @@ -0,0 +1,101 @@ +createHandler('

Hello world!

'); + + $result = $handler->handle( + json_decode(file_get_contents(__DIR__ . '/Fixture/ag-v1-simple.json'), true, 512, JSON_THROW_ON_ERROR), + Context::fake() + ); + + self::assertInstanceOf(Generator::class, $result); + + self::assertSame([ + 'statusCode' => 200, + 'headers' => [], + ], json_decode($result->current(), true, 512, JSON_THROW_ON_ERROR)); + + $result->next(); + self::assertSame("\0\0\0\0\0\0\0\0", $result->current()); + + $result->next(); + self::assertSame('

Hello world!

', $result->current()); + } + + public function test streamed response in API Gateway v2() + { + $handler = $this->createHandler('

Hello world!

'); + + $result = $handler->handle( + json_decode(file_get_contents(__DIR__ . '/Fixture/ag-v2-simple.json'), true, 512, JSON_THROW_ON_ERROR), + Context::fake() + ); + + self::assertInstanceOf(Generator::class, $result); + + self::assertSame([ + 'cookies' => [], + 'statusCode' => 200, + 'headers' => [], + ], json_decode($result->current(), true, 512, JSON_THROW_ON_ERROR)); + + $result->next(); + self::assertSame("\0\0\0\0\0\0\0\0", $result->current()); + + $result->next(); + self::assertSame('

Hello world!

', $result->current()); + } + + public function test warmer invocations are handled() + { + $handler = $this->createHandler(''); + + $result = $handler->handle(['warmer' => true], Context::fake()); + + self::assertSame(['Lambda is warm'], $result); + } + + private function createHandler(string $body): HttpHandler + { + return new class($body) extends HttpHandler { + private string $body; + + public function __construct(string $body) + { + $this->body = $body; + } + + public function handleRequest(HttpRequestEvent $event, Context $context): HttpResponse + { + return new HttpResponse($this->body); + } + }; + } +} diff --git a/tests/Event/Http/HttpResponseStreamedTest.php b/tests/Event/Http/HttpResponseStreamedTest.php new file mode 100644 index 000000000..3897261fd --- /dev/null +++ b/tests/Event/Http/HttpResponseStreamedTest.php @@ -0,0 +1,190 @@ +Hello world!

', [ + 'Content-Type' => 'text/html; charset=utf-8', + ]); + + $generator = $response->toApiGatewayFormat(); + self::assertInstanceOf(Generator::class, $generator); + + self::assertSame([ + 'statusCode' => 200, + 'headers' => [ + 'Content-Type' => 'text/html; charset=utf-8', + ], + ], json_decode($generator->current(), true, 512, JSON_THROW_ON_ERROR)); + + $generator->next(); + self::assertSame("\0\0\0\0\0\0\0\0", $generator->current()); + + $generator->next(); + self::assertSame('

Hello world!

', $generator->current()); + } + + public function test streamed response in API Gateway v2 format() + { + $response = new HttpResponse('

Hello world!

', [ + 'Content-Type' => 'text/html; charset=utf-8', + ]); + + $generator = $response->toApiGatewayFormatV2(); + self::assertInstanceOf(Generator::class, $generator); + + self::assertSame([ + 'cookies' => [], + 'statusCode' => 200, + 'headers' => [ + 'Content-Type' => 'text/html; charset=utf-8', + ], + ], json_decode($generator->current(), true, 512, JSON_THROW_ON_ERROR)); + + $generator->next(); + self::assertSame("\0\0\0\0\0\0\0\0", $generator->current()); + + $generator->next(); + self::assertSame('

Hello world!

', $generator->current()); + } + + public function test streamed response with multi value headers() + { + $response = new HttpResponse('', [ + 'foo' => ['bar', 'baz'], + ]); + + $generator = $response->toApiGatewayFormat(true); + self::assertInstanceOf(Generator::class, $generator); + + self::assertSame([ + 'statusCode' => 200, + 'multiValueHeaders' => [ + 'Foo' => ['bar', 'baz'], + ], + ], json_decode($generator->current(), true, 512, JSON_THROW_ON_ERROR)); + + $generator->next(); + self::assertSame("\0\0\0\0\0\0\0\0", $generator->current()); + + $generator->next(); + self::assertSame('', $generator->current()); + } + + public function test streamed response with cookies() + { + $response = new HttpResponse('', [ + 'set-cookie' => ['foo', 'bar'], + ]); + + $generator = $response->toApiGatewayFormatV2(); + self::assertInstanceOf(Generator::class, $generator); + + self::assertSame([ + 'cookies' => ['foo', 'bar'], + 'statusCode' => 200, + 'headers' => [], + ], json_decode($generator->current(), true, 512, JSON_THROW_ON_ERROR)); + } + + public static function provideStreamedBody(): iterable + { + yield 'single chunk' => [['

Hello world!

']]; + yield 'multiple chunks' => [['Hello', ' ', 'world!']]; + } + + /** + * @param array $chunks + * + * @dataProvider provideStreamedBody + */ + public function test streamed response with a generator body in API Gateway v1 format(array $chunks) + { + $response = new HttpResponse($this->createBodyGenerator($chunks), [ + 'Content-Type' => 'text/html; charset=utf-8', + ], 201); + + $generator = $response->toApiGatewayFormat(); + self::assertInstanceOf(Generator::class, $generator); + + self::assertSame([ + 'statusCode' => 201, + 'headers' => [ + 'Content-Type' => 'text/html; charset=utf-8', + ], + ], json_decode($generator->current(), true, 512, JSON_THROW_ON_ERROR)); + + $generator->next(); + self::assertSame("\0\0\0\0\0\0\0\0", $generator->current()); + + foreach ($chunks as $chunk) { + $generator->next(); + self::assertSame($chunk, $generator->current()); + } + } + + /** + * @param array $chunks + * + * @dataProvider provideStreamedBody + */ + public function test streamed response with a generator body in API Gateway v2 format(array $chunks) + { + $response = new HttpResponse($this->createBodyGenerator($chunks), [ + 'Content-Type' => 'text/html; charset=utf-8', + ], 201); + + $generator = $response->toApiGatewayFormatV2(); + self::assertInstanceOf(Generator::class, $generator); + + self::assertSame([ + 'cookies' => [], + 'statusCode' => 201, + 'headers' => [ + 'Content-Type' => 'text/html; charset=utf-8', + ], + ], json_decode($generator->current(), true, 512, JSON_THROW_ON_ERROR)); + + $generator->next(); + self::assertSame("\0\0\0\0\0\0\0\0", $generator->current()); + + foreach ($chunks as $chunk) { + $generator->next(); + self::assertSame($chunk, $generator->current()); + } + } + + /** + * @param array $chunks + */ + private function createBodyGenerator(array $chunks): Generator + { + foreach ($chunks as $chunk) { + yield $chunk; + } + } +} diff --git a/tests/Event/Http/HttpResponseTest.php b/tests/Event/Http/HttpResponseTest.php index 9eb36c7bf..186a40890 100644 --- a/tests/Event/Http/HttpResponseTest.php +++ b/tests/Event/Http/HttpResponseTest.php @@ -192,4 +192,42 @@ public function test response with multiple cookies() 'body' => '', ], $response->toApiGatewayFormatV2()); } + + public function test non streamed response with a generator body concatenates the chunks() + { + putenv('BREF_STREAMED_MODE=0'); + + $response = new HttpResponse($this->createBodyGenerator(), [ + 'Content-Type' => 'text/html; charset=utf-8', + ]); + + self::assertEquals([ + 'isBase64Encoded' => false, + 'statusCode' => 200, + 'headers' => [ + 'Content-Type' => 'text/html; charset=utf-8', + ], + 'body' => '

Hello world!

', + ], $response->toApiGatewayFormat()); + + $responseV2 = new HttpResponse($this->createBodyGenerator(), [ + 'Content-Type' => 'text/html; charset=utf-8', + ]); + + self::assertEquals([ + 'cookies' => [], + 'isBase64Encoded' => false, + 'statusCode' => 200, + 'headers' => [ + 'Content-Type' => 'text/html; charset=utf-8', + ], + 'body' => '

Hello world!

', + ], $responseV2->toApiGatewayFormatV2()); + } + + private function createBodyGenerator(): \Generator + { + yield '

Hello '; + yield 'world!

'; + } } diff --git a/tests/Runtime/LambdaRuntimeTest.php b/tests/Runtime/LambdaRuntimeTest.php index 1f468d533..19c1bf8d5 100644 --- a/tests/Runtime/LambdaRuntimeTest.php +++ b/tests/Runtime/LambdaRuntimeTest.php @@ -8,6 +8,7 @@ use Bref\Event\EventBridge\EventBridgeHandler; use Bref\Event\Handler; use Bref\Event\Http\HttpRequestEvent; +use Bref\Event\Http\HttpResponse; use Bref\Event\S3\S3Event; use Bref\Event\S3\S3Handler; use Bref\Event\Sns\SnsEvent; @@ -445,4 +446,38 @@ public function test request id env variables() $this->assertSame('1', $requestId); $this->assertSame('Root=1-67891233-abcdef012345678912345678', $traceId); } + + public function test streamed response is sent with the streaming response mode() + { + putenv('BREF_STREAMED_MODE=1'); + try { + $this->givenAnEvent(['Hello' => 'world!']); + + $this->runtime->processNextEvent(function () { + return (new HttpResponse('

Hello world!

', [ + 'Content-Type' => 'text/html; charset=utf-8', + ]))->toApiGatewayFormatV2(); + }); + } finally { + putenv('BREF_STREAMED_MODE=0'); + } + + $requests = Server::received(); + $this->assertCount(2, $requests); + [$eventRequest, $eventStreamResponse] = $requests; + + $this->assertSame('GET', $eventRequest->getMethod()); + $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/next', $eventRequest->getUri()->__toString()); + + $this->assertSame('POST', $eventStreamResponse->getMethod()); + $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/1/response', $eventStreamResponse->getUri()->__toString()); + + $this->assertSame('streaming', $eventStreamResponse->getHeaderLine('lambda-runtime-function-response-mode')); + $this->assertSame('chunked', $eventStreamResponse->getHeaderLine('transfer-encoding')); + $this->assertSame('application/vnd.awslambda.http-integration-response', $eventStreamResponse->getHeaderLine('content-type')); + + $this->assertStringContainsString('"statusCode":200', (string) $eventStreamResponse->getBody()); + $this->assertStringContainsString("\0\0\0\0\0\0\0\0", (string) $eventStreamResponse->getBody()); + $this->assertStringContainsString('

Hello world!

', (string) $eventStreamResponse->getBody()); + } } From 01a496748af874bbb72c2f611f3a921e4a002a39 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Tue, 18 Aug 2026 18:39:59 +0100 Subject: [PATCH 18/18] feat: stream PHP-FPM responses to Lambda with a Fiber Add BREF_STREAMED_MODE support to the FPM runtime: FastCGI STDOUT chunks are now yielded as they arrive via a Fiber that suspends the socket reading loop, so Lambda receives the body incrementally. - FpmHandler streams responses for API Gateway HTTP API events - The response metadata (status code and headers) is returned as soon as PHP-FPM sends them, then the body is streamed - Handle php-fpm coalescing the headers with the first body chunk - Don't signal an invocation failure once a streamed response started --- src/FpmRuntime/FpmHandler.php | 219 ++++++++++++++++++-- src/Runtime/LambdaRuntime.php | 9 +- tests/FpmRuntime/FpmHandlerStreamedTest.php | 171 +++++++++++++++ tests/FpmRuntime/fixtures/streaming.php | 21 ++ tests/Runtime/LambdaRuntimeTest.php | 56 ++++- 5 files changed, 459 insertions(+), 17 deletions(-) create mode 100644 tests/FpmRuntime/FpmHandlerStreamedTest.php create mode 100644 tests/FpmRuntime/fixtures/streaming.php diff --git a/src/FpmRuntime/FpmHandler.php b/src/FpmRuntime/FpmHandler.php index 6931b9caa..1248256f1 100644 --- a/src/FpmRuntime/FpmHandler.php +++ b/src/FpmRuntime/FpmHandler.php @@ -2,6 +2,7 @@ namespace Bref\FpmRuntime; +use Bref\Bref; use Bref\Context\Context; use Bref\Event\Http\HttpHandler; use Bref\Event\Http\HttpRequestEvent; @@ -10,6 +11,7 @@ use Bref\FpmRuntime\FastCgi\FastCgiRequest; use Bref\FpmRuntime\FastCgi\Timeout; use Exception; +use Generator; use hollodotme\FastCGI\Client; use hollodotme\FastCGI\Exceptions\TimedoutException; use hollodotme\FastCGI\Interfaces\ProvidesRequestData; @@ -129,23 +131,160 @@ public function __destruct() /** * Proxy the API Gateway event to PHP-FPM and return its response. * + * When the Lambda function is configured for streaming responses (`BREF_STREAMED_MODE=1`), + * the response body is streamed as PHP-FPM writes it. + * * @throws FastCgiCommunicationFailed * @throws Timeout * @throws Exception */ public function handleRequest(HttpRequestEvent $event, Context $context): HttpResponse { - $request = $this->eventToFastCgiRequest($event, $context); + $isHttpApiEvent = array_key_exists('http', $event->getRequestContext()); + $streamingEnabled = Bref::isRunningInStreamingMode() + && ! (bool) getenv('BREF_STREAM_NO_FIBER') + // Streaming responses are only supported for API Gateway HTTP APIs (not ALB nor REST APIs) + && $isHttpApiEvent; + + if ($streamingEnabled) { + return $this->handleStreamedRequest($event, $context); + } - // The script will timeout 1 second before the remaining time - // to allow some time for Bref/PHP-FPM to recover and cleanup - $margin = 1000; - $timeoutDelayInMs = max(1000, $context->getRemainingTimeInMillis() - $margin); + $response = $this->sendRequestToFastCgi($event, $context); + + if ($response === null) { + // Cannot happen: reading a FastCGI response always returns one + throw new RuntimeException('PHP-FPM returned no response'); + } + + $responseHeaders = $this->getResponseHeaders($response); + + // Extract the status code + if (isset($responseHeaders['status'])) { + $status = (int) (is_array($responseHeaders['status']) ? $responseHeaders['status'][0] : $responseHeaders['status']); + unset($responseHeaders['status']); + } + + $this->ensureStillRunning(); + + return new HttpResponse($response->getBody(), $responseHeaders, $status ?? 200); + } + + /** + * Stream the response of PHP-FPM back to Lambda as PHP-FPM writes it. + * + * The response body is a generator that yields the FastCGI STDOUT chunks as they + * are received. We use a Fiber to suspend the socket reading loop of the FastCGI + * client whenever a chunk arrives, and the generator resumes the Fiber each time + * Lambda asks for more data. + * + * @throws FastCgiCommunicationFailed + * @throws Timeout + * @throws Exception + */ + private function handleStreamedRequest(HttpRequestEvent $event, Context $context): HttpResponse + { + $responseFiber = new \Fiber(function () use ($event, $context): void { + $this->sendRequestToFastCgi( + $event, + $context, + function (string $stdOut = '', string $stdErr = ''): void { + if ($stdOut !== '') { + \Fiber::suspend(['stdout', $stdOut]); + } elseif ($stdErr !== '') { + \Fiber::suspend(['stderr', $stdErr]); + } + }, + false + ); + }); + + // Read chunks from the fiber until we have the full HTTP headers, so that we can + // return the status code and headers along with the streamed body + $outputAccumulator = ''; + $headerEnd = null; + while ($headerEnd === null && ! $responseFiber->isTerminated()) { + [$chunkType, $fiberChunk] = $this->nextFiberChunk($responseFiber); + + if ($fiberChunk === '') { + continue; + } + + if ($chunkType === 'stderr') { + fwrite(STDERR, $fiberChunk); + continue; + } + + $outputAccumulator .= $fiberChunk; + + $position = strpos($outputAccumulator, "\r\n\r\n"); + if ($position !== false) { + $headerEnd = $position; + } + } + + if ($headerEnd !== null) { + $headerBlock = substr($outputAccumulator, 0, $headerEnd); + $bodyStart = substr($outputAccumulator, $headerEnd + 4); + } else { + $headerBlock = $outputAccumulator; + $bodyStart = ''; + } + + [$status, $responseHeaders] = $this->parseResponseHeaders($headerBlock); + + $this->ensureStillRunning(); + + return new HttpResponse( + (function () use ($responseFiber, $bodyStart): Generator { + if ($bodyStart !== '') { + yield $bodyStart; + } + + while (! $responseFiber->isTerminated()) { + [$chunkType, $fiberChunk] = $this->nextFiberChunk($responseFiber); + + if ($chunkType === 'stderr') { + fwrite(STDERR, $fiberChunk); + } elseif ($fiberChunk !== '') { + // We must never yield an empty string: it would be interpreted + // as the end of the streamed response body + yield $fiberChunk; + } + } + })(), + $responseHeaders, + $status + ); + } + + /** + * Send the FastCGI request to PHP-FPM and read its response. + * + * When a $passThroughCallback is provided and $readResponse is false, the response is + * not accumulated: the callback is invoked with each chunk of data as it is received. + * + * @throws FastCgiCommunicationFailed + * @throws Timeout + */ + private function sendRequestToFastCgi( + HttpRequestEvent $event, + Context $context, + ?callable $passThroughCallback = null, + bool $readResponse = true, + ): ?ProvidesResponseData { + $request = $this->eventToFastCgiRequest($event, $context, $passThroughCallback); try { $socketId = $this->client->sendAsyncRequest($this->connection, $request); - $response = $this->client->readResponse($socketId, $timeoutDelayInMs); + if ($readResponse) { + return $this->client->readResponse($socketId, $this->getRequestTimeoutInMs($context)); + } + + $this->client->waitForResponse($socketId, $this->getRequestTimeoutInMs($context)); + + return null; } catch (TimedoutException) { $invocationId = $context->getAwsRequestId(); 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"; @@ -170,7 +309,7 @@ public function handleRequest(HttpRequestEvent $event, Context $context): HttpRe // - this is reported as a Lambda execution error ("error rate" metrics are accurate) // - the CloudWatch logs correctly reflect that an execution error occurred // - the 500 response is the same as if an exception happened in Bref - throw new Timeout($timeoutDelayInMs, $context->getAwsRequestId()); + throw new Timeout($this->getRequestTimeoutInMs($context), $context->getAwsRequestId()); } catch (Throwable $e) { printf( "Error communicating with PHP-FPM to read the HTTP response. Bref will restart PHP-FPM now. Original exception message: %s %s\n", @@ -184,18 +323,64 @@ public function handleRequest(HttpRequestEvent $event, Context $context): HttpRe throw new FastCgiCommunicationFailed; } + } - $responseHeaders = $this->getResponseHeaders($response); + private function getRequestTimeoutInMs(Context $context): int + { + // The script will timeout 1 second before the remaining time + // to allow some time for Bref/PHP-FPM to recover and cleanup + $margin = 1000; - // Extract the status code - if (isset($responseHeaders['status'])) { - $status = (int) (is_array($responseHeaders['status']) ? $responseHeaders['status'][0] : $responseHeaders['status']); - unset($responseHeaders['status']); + return max(1000, $context->getRemainingTimeInMillis() - $margin); + } + + /** + * Start or resume the fiber and return the next chunk it suspends with. + * + * @return array{0: string, 1: string} + */ + private function nextFiberChunk(\Fiber $fiber): array + { + if ($fiber->isTerminated()) { + return ['', '']; } - $this->ensureStillRunning(); + $result = $fiber->isStarted() || $fiber->isSuspended() ? $fiber->resume() : $fiber->start(); - return new HttpResponse($response->getBody(), $responseHeaders, $status ?? 200); + return is_array($result) ? $result : ['', '']; + } + + /** + * Parse a raw FastCGI header block into a status code and a list of headers. + * + * @return array{0: int, 1: array} + */ + private function parseResponseHeaders(string $headerBlock): array + { + $status = 200; + $headers = []; + + $normalized = str_replace(["\r\n", "\r"], "\n", $headerBlock); + foreach (explode("\n", $normalized) as $line) { + if (preg_match('/^Status:\s*(\d{3})/i', $line, $matches) === 1) { + $status = (int) $matches[1]; + continue; + } + + $separator = strpos($line, ':'); + if ($separator === false) { + continue; + } + + $name = strtolower(trim(substr($line, 0, $separator))); + $value = trim(substr($line, $separator + 1)); + + if ($name !== '') { + $headers[$name] = $value; + } + } + + return [$status, $headers]; } /** @@ -240,7 +425,7 @@ private function isReady(): bool return file_exists(self::SOCKET); } - private function eventToFastCgiRequest(HttpRequestEvent $event, Context $context): ProvidesRequestData + private function eventToFastCgiRequest(HttpRequestEvent $event, Context $context, ?callable $passThroughCallback = null): ProvidesRequestData { $request = new FastCgiRequest($event->getMethod(), $this->handler, $event->getBody()); $request->setRequestUri($event->getUri()); @@ -255,6 +440,10 @@ private function eventToFastCgiRequest(HttpRequestEvent $event, Context $context $request->setCustomVar('LAMBDA_INVOCATION_CONTEXT', json_encode($context, JSON_THROW_ON_ERROR)); $request->setCustomVar('LAMBDA_REQUEST_CONTEXT', json_encode($event->getRequestContext(), JSON_THROW_ON_ERROR)); + if ($passThroughCallback !== null) { + $request->addPassThroughCallbacks($passThroughCallback); + } + $contentType = $event->getContentType(); if ($contentType) { $request->setContentType($contentType); diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php index 546c30713..660963c76 100755 --- a/src/Runtime/LambdaRuntime.php +++ b/src/Runtime/LambdaRuntime.php @@ -99,7 +99,14 @@ public function processNextEvent(Handler | RequestHandlerInterface | callable $h $this->sendResponse($context->getAwsRequestId(), $result); } catch (Throwable $e) { - $this->signalFailure($context->getAwsRequestId(), $e); + if (isset($result) && $result instanceof Generator) { + // We cannot signal a failure once a streamed response has started: Lambda + // would reject the error report with an "InvalidStateTransition" error. + // The error is only logged, the caller will see a truncated response. + $this->logError($e, $context->getAwsRequestId()); + } else { + $this->signalFailure($context->getAwsRequestId(), $e); + } try { Bref::events()->afterInvoke($handler, $event, $context, null, $e); diff --git a/tests/FpmRuntime/FpmHandlerStreamedTest.php b/tests/FpmRuntime/FpmHandlerStreamedTest.php new file mode 100644 index 000000000..e1804769e --- /dev/null +++ b/tests/FpmRuntime/FpmHandlerStreamedTest.php @@ -0,0 +1,171 @@ +fakeContext = new Context('abc', time(), 'abc', 'abc'); + } + + public function tearDown(): void + { + $this->fpm?->stop(); + putenv('BREF_STREAMED_MODE=0'); + } + + private function startFpm(string $fixture = 'streaming.php'): void + { + $this->fpm = new FpmHandler(__DIR__ . "/fixtures/$fixture", __DIR__ . '/fixtures/php-fpm.conf'); + $this->fpm->start(); + } + + /** + * Returns an API Gateway HTTP API (version 2.0) event. + */ + private function getHttpApiEvent(): array + { + return [ + 'version' => '2.0', + 'routeKey' => 'ANY /{proxy+}', + 'rawPath' => '/', + 'rawQueryString' => '', + 'headers' => [], + 'requestContext' => [ + 'accountId' => '123456789012', + 'apiId' => 'api-id', + 'domainName' => 'id.execute-api.us-east-1.amazonaws.com', + 'http' => [ + 'method' => 'GET', + 'path' => '/', + 'protocol' => 'HTTP/1.1', + 'sourceIp' => '127.0.0.1', + 'userAgent' => 'Test', + ], + 'requestId' => 'id', + 'routeKey' => 'ANY /{proxy+}', + 'stage' => '$default', + 'time' => '12/Mar/2020:19:03:58 +0000', + 'timeEpoch' => 1583348638391, + ], + ]; + } + + public function test streamed response is returned as a generator() + { + $this->startFpm(); + + $result = $this->fpm->handle($this->getHttpApiEvent(), $this->fakeContext); + + self::assertInstanceOf(Generator::class, $result); + } + + public function test streamed response sends chunks incrementally() + { + $this->startFpm(); + + $result = $this->fpm->handle($this->getHttpApiEvent(), $this->fakeContext); + self::assertInstanceOf(Generator::class, $result); + + // The first yield of the streamed format is the response metadata (status code and headers) + $result->rewind(); + $metadata = json_decode((string) $result->current(), true, 512, JSON_THROW_ON_ERROR); + $result->next(); + + self::assertSame(201, $metadata['statusCode']); + self::assertSame('streamed-value', $metadata['headers']['X-Custom-Header']); + + // The body is streamed chunk by chunk: the first chunk arrives as soon as + // PHP-FPM starts emitting it, while the total response takes longer than + // one blocking FastCGI read + $start = microtime(true); + $firstChunkTime = null; + $body = ''; + while ($result->valid()) { + $body .= (string) $result->current(); + $firstChunkTime ??= microtime(true); + $result->next(); + } + $totalTime = microtime(true) - $start; + + // The streamed body is prefixed with the null-byte separator of the Lambda Streaming Format + self::assertSame("\0\0\0\0\0\0\0\0" . 'chunk-1chunk-2chunk-3', $body); + self::assertLessThan(0.15, $firstChunkTime - $start); + self::assertGreaterThan(0.25, $totalTime); + } + + public function test streamed response keeps the response headers() + { + $this->startFpm(); + + $result = $this->fpm->handle($this->getHttpApiEvent(), $this->fakeContext); + self::assertInstanceOf(Generator::class, $result); + + $result->rewind(); + $metadata = json_decode((string) $result->current(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame('text/plain;charset=UTF-8', $metadata['headers']['Content-Type']); + } + + public function test response is not streamed when streamed mode is disabled() + { + putenv('BREF_STREAMED_MODE=0'); + + $this->startFpm(); + + $result = $this->fpm->handle($this->getHttpApiEvent(), $this->fakeContext); + + self::assertIsArray($result); + self::assertSame(201, $result['statusCode']); + self::assertSame('chunk-1chunk-2chunk-3', $result['body']); + self::assertSame('streamed-value', $result['headers']['X-Custom-Header']); + } + + public function test response is not streamed from FPM for non HTTP API events even in streamed mode() + { + $this->startFpm(); + + // ALB events do not contain `requestContext.http`: the response is not streamed + // from PHP-FPM, but the streamed format is still applied by the runtime + $albEvent = [ + 'requestContext' => [ + 'elb' => ['targetGroupArn' => 'arn:aws:elasticloadbalancing:...'], + ], + 'httpMethod' => 'GET', + 'path' => '/', + 'headers' => [], + 'body' => '', + 'isBase64Encoded' => false, + ]; + + $result = $this->fpm->handle($albEvent, $this->fakeContext); + + self::assertInstanceOf(Generator::class, $result); + + $result->rewind(); + $metadata = json_decode((string) $result->current(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame(201, $metadata['statusCode']); + + $result->next(); + self::assertSame("\0\0\0\0\0\0\0\0", $result->current()); + + $result->next(); + self::assertSame('chunk-1chunk-2chunk-3', $result->current()); + } +} diff --git a/tests/FpmRuntime/fixtures/streaming.php b/tests/FpmRuntime/fixtures/streaming.php new file mode 100644 index 000000000..2b784017f --- /dev/null +++ b/tests/FpmRuntime/fixtures/streaming.php @@ -0,0 +1,21 @@ +givenAnEvent(['Hello' => 'world!']); $this->runtime->processNextEvent(function () { - return (new HttpResponse('

Hello world!

', [ + $generator = (function () { + yield '

Hello world!

'; + })(); + + return (new HttpResponse($generator, [ 'Content-Type' => 'text/html; charset=utf-8', ]))->toApiGatewayFormatV2(); }); @@ -480,4 +484,54 @@ public function test streamed response is sent with the streaming respon $this->assertStringContainsString("\0\0\0\0\0\0\0\0", (string) $eventStreamResponse->getBody()); $this->assertStringContainsString('

Hello world!

', (string) $eventStreamResponse->getBody()); } + + public function test response with a string body is streamed in streamed mode() + { + putenv('BREF_STREAMED_MODE=1'); + try { + $this->givenAnEvent(['Hello' => 'world!']); + + $this->runtime->processNextEvent(function () { + return (new HttpResponse('

Hello world!

', [ + 'Content-Type' => 'text/html; charset=utf-8', + ]))->toApiGatewayFormatV2(); + }); + } finally { + putenv('BREF_STREAMED_MODE=0'); + } + + $requests = Server::received(); + $this->assertCount(2, $requests); + [$eventRequest, $eventResponse] = $requests; + + $this->assertSame('GET', $eventRequest->getMethod()); + + $this->assertSame('POST', $eventResponse->getMethod()); + $this->assertSame('streaming', $eventResponse->getHeaderLine('lambda-runtime-function-response-mode')); + $this->assertStringContainsString("\0\0\0\0\0\0\0\0", (string) $eventResponse->getBody()); + $this->assertStringContainsString('

Hello world!

', (string) $eventResponse->getBody()); + } + + public function test an error during streaming does not signal a failure() + { + $this->givenAnEvent(['Hello' => 'world!']); + + $this->runtime->processNextEvent(function () { + return (function () { + yield 'chunk-1'; + throw new Exception('Boom during streaming'); + })(); + }); + + // No error response should be sent once the streaming has started + foreach (Server::received() as $request) { + $this->assertNotSame( + 'http://localhost:8126/2018-06-01/runtime/invocation/1/error', + $request->getUri()->__toString(), + 'an error must not be signaled once streaming has started' + ); + } + + $this->assertErrorInLogs('Exception', 'Boom during streaming'); + } }