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');
+ }
}