diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 95ff2f2..f44c909 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -16,10 +16,13 @@ jobs:
php-version: '8.1'
- name: Setup Docker
- run: docker compose up -d --build
+ run: docker compose up -d --build --wait
- - name: Wait for Server to be ready
- run: sleep 10
+ - name: Run Unit Tests
+ run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite unit
- - name: Run Tests
- run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml
\ No newline at end of file
+ - name: Run FPM E2E Tests
+ run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite fpm
+
+ - name: Run Swoole E2E Tests
+ run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite swoole
diff --git a/Dockerfile.swoole b/Dockerfile.swoole
new file mode 100644
index 0000000..5b3d752
--- /dev/null
+++ b/Dockerfile.swoole
@@ -0,0 +1,20 @@
+FROM phpswoole/swoole:php8.3-alpine
+
+ARG TESTING=true
+ENV TESTING=$TESTING
+
+WORKDIR /usr/src/code
+
+COPY composer.* /usr/src/code/
+
+RUN composer install --ignore-platform-reqs --optimize-autoloader \
+ --no-plugins --no-scripts --prefer-dist \
+ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi`
+
+COPY ./src /usr/src/code/src
+COPY ./tests /usr/src/code/tests
+COPY ./phpunit.xml /usr/src/code/phpunit.xml
+
+EXPOSE 80
+
+CMD ["php", "/usr/src/code/tests/e2e/swoole-server.php"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 6326fe3..b37251b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,4 +5,24 @@ services:
- "9020:80"
volumes:
- ./src:/usr/share/nginx/html/src
- - ./tests:/usr/share/nginx/html/tests
\ No newline at end of file
+ - ./tests:/usr/share/nginx/html/tests
+ - ./phpunit.xml:/usr/share/nginx/html/phpunit.xml
+ healthcheck:
+ test: ["CMD-SHELL", "curl -sf http://127.0.0.1/ || exit 1"]
+ interval: 2s
+ timeout: 5s
+ retries: 15
+ swoole-web:
+ build:
+ context: .
+ dockerfile: Dockerfile.swoole
+ ports:
+ - "9021:80"
+ volumes:
+ - ./src:/usr/src/code/src
+ - ./tests:/usr/src/code/tests
+ healthcheck:
+ test: ["CMD-SHELL", "curl -sf http://127.0.0.1/ || exit 1"]
+ interval: 2s
+ timeout: 5s
+ retries: 15
diff --git a/phpunit.xml b/phpunit.xml
index de6deb0..8bc8156 100755
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -9,9 +9,19 @@
stopOnFailure="false"
>
-
+
+ ./tests/
+ ./tests/e2e
+
+
+ ./tests/e2e/Client.php
+ ./tests/e2e/
+ ./tests/e2e/SwooleResponseTest.php
+
+
./tests/e2e/Client.php
- ./tests/
+ ./tests/e2e/ResponseTest.php
+ ./tests/e2e/SwooleResponseTest.php
-
\ No newline at end of file
+
diff --git a/src/Http/Adapter/Swoole/Request.php b/src/Http/Adapter/Swoole/Request.php
index 094f81c..2d97fda 100644
--- a/src/Http/Adapter/Swoole/Request.php
+++ b/src/Http/Adapter/Swoole/Request.php
@@ -374,6 +374,18 @@ protected function generateInput(): array
*/
protected function generateHeaders(): array
{
- return $this->swoole->header;
+ $headers = $this->swoole->header ?? [];
+
+ // Swoole parses the Cookie header into $swoole->cookie and removes it
+ // from the header array. Reconstruct it so callers can access it.
+ if (!isset($headers['cookie']) && !empty($this->swoole->cookie)) {
+ $pairs = [];
+ foreach ($this->swoole->cookie as $k => $v) {
+ $pairs[] = $k . '=' . $v;
+ }
+ $headers['cookie'] = implode('; ', $pairs);
+ }
+
+ return $headers;
}
}
diff --git a/src/Http/Adapter/Swoole/Response.php b/src/Http/Adapter/Swoole/Response.php
index 95188e1..62f0688 100644
--- a/src/Http/Adapter/Swoole/Response.php
+++ b/src/Http/Adapter/Swoole/Response.php
@@ -3,6 +3,7 @@
namespace Utopia\Http\Adapter\Swoole;
use Swoole\Http\Response as SwooleResponse;
+use Swoole\Http\Server as SwooleServer;
use Utopia\Http\Response as UtopiaResponse;
class Response extends UtopiaResponse
@@ -14,6 +15,22 @@ class Response extends UtopiaResponse
*/
protected SwooleResponse $swoole;
+ /**
+ * Swoole Server Object (needed for raw TCP streaming with Content-Length)
+ *
+ * @var SwooleServer|null
+ */
+ protected ?SwooleServer $server = null;
+
+ /**
+ * Whether to use detach() + raw TCP send for streaming.
+ * When true (and server is set): preserves Content-Length header so browsers show download progress.
+ * When false: uses Swoole's write() which forces chunked Transfer-Encoding.
+ *
+ * @var bool
+ */
+ protected bool $detach = true;
+
/**
* Response constructor.
*/
@@ -23,6 +40,37 @@ public function __construct(SwooleResponse $response)
parent::__construct(\microtime(true));
}
+ /**
+ * Set the Swoole server instance for raw TCP streaming.
+ *
+ * @param SwooleServer $server
+ * @return static
+ */
+ public function setServer(SwooleServer $server): static
+ {
+ $this->server = $server;
+
+ return $this;
+ }
+
+ /**
+ * Set whether to detach from Swoole's HTTP layer for streaming.
+ *
+ * When enabled (default): uses detach() + $server->send() to preserve
+ * Content-Length so browsers show download progress bars.
+ *
+ * When disabled: uses Swoole's write() which applies chunked Transfer-Encoding.
+ *
+ * @param bool $detach
+ * @return static
+ */
+ public function setDetach(bool $detach): static
+ {
+ $this->detach = $detach;
+
+ return $this;
+ }
+
/**
* Write
*
@@ -45,6 +93,169 @@ public function end(?string $content = null): void
$this->swoole->end($content);
}
+ /**
+ * Stream response
+ *
+ * Uses detach() + $server->send() for raw TCP streaming that preserves
+ * Content-Length (so browsers show download progress). Falls back to
+ * the base class implementation if no server instance is available.
+ *
+ * @param callable|\Generator $source Either a callable($offset, $length) or a Generator yielding string chunks
+ * @param int $totalSize Total size of the content in bytes
+ * @return void
+ */
+ public function stream(callable|\Generator $source, int $totalSize): void
+ {
+ if ($this->sent) {
+ return;
+ }
+
+ $this->sent = true;
+
+ if ($this->disablePayload) {
+ $this->appendCookies();
+ $this->appendHeaders();
+ $this->swoole->end();
+ $this->disablePayload();
+
+ return;
+ }
+
+ // When detach is enabled and server is available, use raw TCP streaming
+ // to preserve Content-Length (browsers show download progress).
+ if ($this->detach && $this->server !== null) {
+ $this->streamDetached($source, $totalSize);
+
+ return;
+ }
+
+ // Non-detach path: use Swoole's write() (chunked Transfer-Encoding)
+ $this->addHeader('X-Debug-Speed', (string) (microtime(true) - $this->startTime), override: true);
+
+ if (!empty($this->contentType)) {
+ $this->addHeader('Content-Type', $this->contentType, override: true);
+ }
+
+ $this->appendCookies();
+ $this->appendHeaders();
+ $this->sendStatus($this->statusCode);
+
+ if ($source instanceof \Generator) {
+ foreach ($source as $chunk) {
+ if (!empty($chunk)) {
+ $this->size += strlen($chunk);
+ if ($this->swoole->write($chunk) === false) {
+ break;
+ }
+ }
+ }
+ } else {
+ $length = self::CHUNK_SIZE;
+ for ($offset = 0; $offset < $totalSize; $offset += $length) {
+ $chunk = $source($offset, min($length, $totalSize - $offset));
+ if (!empty($chunk)) {
+ $this->size += strlen($chunk);
+ if ($this->swoole->write($chunk) === false) {
+ break;
+ }
+ }
+ }
+ }
+
+ $this->swoole->end();
+ $this->disablePayload();
+ }
+
+ /**
+ * Stream using detach() + raw TCP send to preserve Content-Length.
+ *
+ * @param callable|\Generator $source
+ * @param int $totalSize
+ * @return void
+ */
+ protected function streamDetached(callable|\Generator $source, int $totalSize): void
+ {
+ $this->addHeader('Content-Length', (string) $totalSize, override: true);
+ $this->addHeader('Connection', 'close', override: true);
+ $this->addHeader('X-Debug-Speed', (string) (microtime(true) - $this->startTime), override: true);
+
+ if (!empty($this->contentType)) {
+ $this->addHeader('Content-Type', $this->contentType, override: true);
+ }
+
+ $statusReason = $this->getStatusCodeReason($this->statusCode);
+ $rawHeaders = "HTTP/1.1 {$this->statusCode} {$statusReason}\r\n";
+
+ foreach ($this->headers as $key => $value) {
+ if (\is_array($value)) {
+ foreach ($value as $v) {
+ $rawHeaders .= "{$key}: {$v}\r\n";
+ }
+ } else {
+ $rawHeaders .= "{$key}: {$value}\r\n";
+ }
+ }
+
+ foreach ($this->cookies as $cookie) {
+ $cookieStr = \urlencode($cookie['name']) . '=' . \urlencode($cookie['value'] ?? '');
+ if (!empty($cookie['expire'])) {
+ $cookieStr .= '; Expires=' . \gmdate('D, d M Y H:i:s T', $cookie['expire']);
+ }
+ if (!empty($cookie['path'])) {
+ $cookieStr .= '; Path=' . $cookie['path'];
+ }
+ if (!empty($cookie['domain'])) {
+ $cookieStr .= '; Domain=' . $cookie['domain'];
+ }
+ if (!empty($cookie['secure'])) {
+ $cookieStr .= '; Secure';
+ }
+ if (!empty($cookie['httponly'])) {
+ $cookieStr .= '; HttpOnly';
+ }
+ if (!empty($cookie['samesite'])) {
+ $cookieStr .= '; SameSite=' . $cookie['samesite'];
+ }
+ $rawHeaders .= "Set-Cookie: {$cookieStr}\r\n";
+ }
+
+ $rawHeaders .= "\r\n";
+
+ $fd = $this->swoole->fd;
+ $this->swoole->detach();
+
+ if ($this->server->send($fd, $rawHeaders) === false) {
+ $this->disablePayload();
+
+ return;
+ }
+
+ if ($source instanceof \Generator) {
+ foreach ($source as $chunk) {
+ if (!empty($chunk)) {
+ $this->size += strlen($chunk);
+ if ($this->server->send($fd, $chunk) === false) {
+ break;
+ }
+ }
+ }
+ } else {
+ $length = self::CHUNK_SIZE;
+ for ($offset = 0; $offset < $totalSize; $offset += $length) {
+ $chunk = $source($offset, min($length, $totalSize - $offset));
+ if (!empty($chunk)) {
+ $this->size += strlen($chunk);
+ if ($this->server->send($fd, $chunk) === false) {
+ break;
+ }
+ }
+ }
+ }
+
+ $this->server->close($fd);
+ $this->disablePayload();
+ }
+
/**
* Get status code reason
*
diff --git a/src/Http/Adapter/Swoole/Server.php b/src/Http/Adapter/Swoole/Server.php
index f39044a..f3b5110 100644
--- a/src/Http/Adapter/Swoole/Server.php
+++ b/src/Http/Adapter/Swoole/Server.php
@@ -26,7 +26,9 @@ public function onRequest(callable $callback)
Http::setResource('swooleRequest', fn () => $request);
Http::setResource('swooleResponse', fn () => $response);
- call_user_func($callback, new Request($request), new Response($response));
+ $utopiaResponse = new Response($response);
+ $utopiaResponse->setServer($this->server);
+ call_user_func($callback, new Request($request), $utopiaResponse);
});
}
diff --git a/src/Http/Http.php b/src/Http/Http.php
index c031750..0a3b3dd 100755
--- a/src/Http/Http.php
+++ b/src/Http/Http.php
@@ -885,6 +885,8 @@ public function run(Request $request, Response $response): static
*/
private function runInternal(Request $request, Response $response): static
{
+ $this->route = null;
+
if ($this->compression) {
$response->setAcceptEncoding($request->getHeader('accept-encoding', ''));
$response->setCompressionMinSize($this->compressionMinSize);
diff --git a/src/Http/Response.php b/src/Http/Response.php
index 87ec85f..bfc5e38 100755
--- a/src/Http/Response.php
+++ b/src/Http/Response.php
@@ -991,6 +991,57 @@ public function iframe(string $callback, array $data): void
->send('');
}
+ /**
+ * Stream response
+ *
+ * Stream file content to the client using either a callable (pull-based, offset/length)
+ * or a Generator/iterable (push-based, yielding chunks from a streaming source).
+ *
+ * @param callable|\Generator $source Either a callable($offset, $length) or a Generator yielding string chunks
+ * @param int $totalSize Total size of the content in bytes
+ * @return void
+ */
+ public function stream(callable|\Generator $source, int $totalSize): void
+ {
+ if ($this->sent) {
+ return;
+ }
+
+ $this->addHeader('Content-Length', (string) $totalSize, override: true);
+ $this->addHeader('X-Debug-Speed', (string) (microtime(true) - $this->startTime), override: true);
+
+ $this->appendCookies();
+ $this->appendHeaders();
+
+ if ($this->disablePayload) {
+ $this->end();
+ $this->sent = true;
+ return;
+ }
+
+ if ($source instanceof \Generator) {
+ foreach ($source as $chunk) {
+ if (!empty($chunk)) {
+ $this->size += strlen($chunk);
+ $this->write($chunk);
+ }
+ }
+ } else {
+ $length = self::CHUNK_SIZE;
+ for ($offset = 0; $offset < $totalSize; $offset += $length) {
+ $chunk = $source($offset, $length);
+ if (!empty($chunk)) {
+ $this->size += strlen($chunk);
+ $this->write($chunk);
+ }
+ }
+ }
+
+ $this->end();
+ $this->sent = true;
+ $this->disablePayload();
+ }
+
/**
* No Content
*
diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php
index 200370f..6d6dd82 100755
--- a/tests/ResponseTest.php
+++ b/tests/ResponseTest.php
@@ -171,4 +171,168 @@ public function testCanSendIframe()
$this->assertEquals('', $html);
$this->assertEquals('text/html; charset=UTF-8', $this->response->getContentType());
}
+
+ public function testStreamWithCallable()
+ {
+ $data = str_repeat('A', 100);
+
+ ob_start();
+
+ @$this->response->stream(function (int $offset, int $length) use ($data) {
+ return substr($data, $offset, $length);
+ }, strlen($data));
+
+ $output = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals($data, $output);
+ $this->assertTrue($this->response->isSent());
+ $this->assertEquals(strlen($data), $this->response->getSize());
+ }
+
+ public function testStreamWithGenerator()
+ {
+ $chunks = ['Hello ', 'World', '!'];
+ $expected = implode('', $chunks);
+
+ $generator = (function () use ($chunks) {
+ foreach ($chunks as $chunk) {
+ yield $chunk;
+ }
+ })();
+
+ ob_start();
+
+ @$this->response->stream($generator, strlen($expected));
+
+ $output = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals($expected, $output);
+ $this->assertTrue($this->response->isSent());
+ $this->assertEquals(strlen($expected), $this->response->getSize());
+ }
+
+ public function testStreamWithGeneratorLargeData()
+ {
+ $chunkSize = 1000000; // 1MB chunks
+ $numChunks = 3;
+ $totalSize = $chunkSize * $numChunks;
+
+ $generator = (function () use ($chunkSize, $numChunks) {
+ for ($i = 0; $i < $numChunks; $i++) {
+ yield str_repeat(chr(65 + $i), $chunkSize);
+ }
+ })();
+
+ ob_start();
+
+ @$this->response->stream($generator, $totalSize);
+
+ $output = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals($totalSize, strlen($output));
+ $this->assertEquals(str_repeat('A', $chunkSize), substr($output, 0, $chunkSize));
+ $this->assertEquals(str_repeat('B', $chunkSize), substr($output, $chunkSize, $chunkSize));
+ $this->assertEquals(str_repeat('C', $chunkSize), substr($output, $chunkSize * 2, $chunkSize));
+ $this->assertEquals($totalSize, $this->response->getSize());
+ }
+
+ public function testStreamWithCallableMultipleChunks()
+ {
+ // Data larger than CHUNK_SIZE to test offset/length loop
+ $data = str_repeat('X', Response::CHUNK_SIZE + 500);
+
+ ob_start();
+
+ @$this->response->stream(function (int $offset, int $length) use ($data) {
+ return substr($data, $offset, $length);
+ }, strlen($data));
+
+ $output = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals($data, $output);
+ $this->assertEquals(strlen($data), $this->response->getSize());
+ }
+
+ public function testStreamDoesNotSendTwice()
+ {
+ $generator = (function () {
+ yield 'first';
+ })();
+
+ ob_start();
+
+ @$this->response->stream($generator, 5);
+
+ // Try streaming again — should be a no-op
+ $secondGenerator = (function () {
+ yield 'second';
+ })();
+
+ @$this->response->stream($secondGenerator, 6);
+
+ $output = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals('first', $output);
+ }
+
+ public function testStreamWithDisabledPayload()
+ {
+ $this->response->disablePayload();
+
+ $generator = (function () {
+ yield 'should not appear';
+ })();
+
+ ob_start();
+
+ @$this->response->stream($generator, 20);
+
+ $output = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals('', $output);
+ $this->assertTrue($this->response->isSent());
+ }
+
+ public function testStreamWithEmptyGenerator()
+ {
+ /** @var \Generator $generator */
+ $generator = (function (): \Generator {
+ yield from [];
+ })();
+
+ ob_start();
+
+ @$this->response->stream($generator, 0);
+
+ $output = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals('', $output);
+ $this->assertTrue($this->response->isSent());
+ $this->assertEquals(0, $this->response->getSize());
+ }
+
+ public function testStreamSetsContentLengthHeader()
+ {
+ $data = 'test content';
+
+ $generator = (function () use ($data) {
+ yield $data;
+ })();
+
+ ob_start();
+
+ @$this->response->stream($generator, strlen($data));
+
+ ob_end_clean();
+
+ $headers = $this->response->getHeaders();
+ $this->assertEquals((string) strlen($data), $headers['Content-Length']);
+ }
}
diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php
index 03bc1e2..fba0c3e 100644
--- a/tests/e2e/Client.php
+++ b/tests/e2e/Client.php
@@ -34,8 +34,11 @@ class Client
/**
* SDK constructor.
*/
- public function __construct()
+ public function __construct(?string $baseUrl = null)
{
+ if ($baseUrl !== null) {
+ $this->baseUrl = $baseUrl;
+ }
}
/**
diff --git a/tests/e2e/ResponseTest.php b/tests/e2e/ResponseTest.php
index 4c7b0c1..2c9a062 100644
--- a/tests/e2e/ResponseTest.php
+++ b/tests/e2e/ResponseTest.php
@@ -111,15 +111,15 @@ public function testDoubleSlash()
$this->assertEquals('Hello World!', $response['body']);
$response = $this->client->call(Client::METHOD_GET, '//value/123');
- $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(404, $response['headers']['status-code']);
$this->assertEmpty($response['body']);
$response = $this->client->call(Client::METHOD_GET, '/value//123');
- $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(400, $response['headers']['status-code']);
$this->assertEmpty($response['body']);
$response = $this->client->call(Client::METHOD_GET, '//value//123');
- $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(404, $response['headers']['status-code']);
$this->assertEmpty($response['body']);
}
@@ -168,4 +168,32 @@ public function testSetCookie()
$this->assertEquals('value1', $response['cookies']['key1']);
$this->assertEquals('value2', $response['cookies']['key2']);
}
+
+ // ── Streaming tests (FPM adapter) ──
+
+ public function testStreamWithGenerator(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/stream/generator');
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('chunk1-chunk2-chunk3', $response['body']);
+ }
+
+ public function testStreamWithCallable(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/stream/callable');
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(str_repeat('A', 1000), $response['body']);
+ }
+
+ public function testStreamWithGeneratorLargeData(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/stream/generator-large');
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(500000, strlen($response['body']));
+ $this->assertEquals(str_repeat('A', 100000), substr($response['body'], 0, 100000));
+ $this->assertEquals(str_repeat('E', 100000), substr($response['body'], 400000, 100000));
+ }
}
diff --git a/tests/e2e/SwooleResponseTest.php b/tests/e2e/SwooleResponseTest.php
new file mode 100644
index 0000000..c8e7b5c
--- /dev/null
+++ b/tests/e2e/SwooleResponseTest.php
@@ -0,0 +1,115 @@
+client = new Client('http://' . $host);
+ }
+
+ // ── Detach mode (default): preserves Content-Length ──
+
+ public function testDetachStreamGeneratorHasContentLength(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/stream/generator');
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('chunk1-chunk2-chunk3', $response['body']);
+ $this->assertArrayHasKey('content-length', $response['headers']);
+ $this->assertEquals('20', $response['headers']['content-length']);
+ }
+
+ public function testDetachStreamCallableHasContentLength(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/stream/callable');
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(str_repeat('A', 1000), $response['body']);
+ $this->assertArrayHasKey('content-length', $response['headers']);
+ $this->assertEquals('1000', $response['headers']['content-length']);
+ }
+
+ public function testDetachStreamGeneratorLargeDataHasContentLength(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/stream/generator-large');
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(500000, strlen($response['body']));
+ $this->assertEquals('500000', $response['headers']['content-length']);
+ $this->assertEquals(str_repeat('A', 100000), substr($response['body'], 0, 100000));
+ $this->assertEquals(str_repeat('E', 100000), substr($response['body'], 400000, 100000));
+ }
+
+ // ── Non-detach mode: chunked Transfer-Encoding, no Content-Length ──
+
+ public function testNonDetachStreamGenerator(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/stream/non-detach/generator');
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('nd-chunk1-nd-chunk2-nd-chunk3', $response['body']);
+ $this->assertArrayNotHasKey('content-length', $response['headers']);
+ }
+
+ /**
+ * Override: Swoole parses cookies internally and the reconstructed Cookie header
+ * always uses '; ' separator, so 'cookie1=value1;cookie2=value2' becomes
+ * 'cookie1=value1; cookie2=value2'. We test the normalized format here.
+ */
+ public function testCookie()
+ {
+ // One cookie
+ $cookie = 'cookie1=value1';
+ $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => $cookie]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals($cookie, $response['body']);
+
+ // Two cookies
+ $cookie = 'cookie1=value1; cookie2=value2';
+ $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => $cookie]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals($cookie, $response['body']);
+
+ // Two cookies without optional space (Swoole normalizes to '; ')
+ $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => 'cookie1=value1;cookie2=value2']);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('cookie1=value1; cookie2=value2', $response['body']);
+
+ // Cookie with "=" in value
+ $cookie = 'cookie1=value1=value2';
+ $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => $cookie]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals($cookie, $response['body']);
+
+ // Case sensitivity for cookie names (Swoole lowercases keys)
+ $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => 'cookie1=v1; Cookie1=v2']);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertNotEmpty($response['body']);
+ }
+
+ public function testNonDetachStreamCallable(): void
+ {
+ $response = $this->client->call(Client::METHOD_GET, '/stream/non-detach/callable');
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals(str_repeat('B', 1000), $response['body']);
+ $this->assertArrayNotHasKey('content-length', $response['headers']);
+ }
+}
diff --git a/tests/e2e/routes.php b/tests/e2e/routes.php
new file mode 100644
index 0000000..49741fb
--- /dev/null
+++ b/tests/e2e/routes.php
@@ -0,0 +1,163 @@
+inject('response')
+ ->action(function (Response $response) {
+ $response->send('Hello World!');
+ });
+
+Http::get('/value/:value')
+ ->param('value', '', new Text(64))
+ ->inject('response')
+ ->action(function (string $value, Response $response) {
+ $response->send($value);
+ });
+
+Http::get('/cookies')
+ ->inject('request')
+ ->inject('response')
+ ->action(function (Request $request, Response $response) {
+ $response->send($request->getHeaders()['cookie'] ?? '');
+ });
+
+Http::get('/set-cookie')
+ ->inject('request')
+ ->inject('response')
+ ->action(function (Request $request, Response $response) {
+ $response->addHeader('Set-Cookie', 'key1=value1');
+ $response->addHeader('Set-Cookie', 'key2=value2');
+ $response->send('OK');
+ });
+
+Http::get('/set-cookie-no-override')
+ ->inject('request')
+ ->inject('response')
+ ->action(function (Request $request, Response $response) {
+ $response->addHeader('Set-Cookie', 'key1=value1', override: false);
+ $response->addHeader('Set-Cookie', 'key2=value2', override: false);
+ $response->send('OK');
+ });
+
+Http::get('/chunked')
+ ->inject('response')
+ ->action(function (Response $response) {
+ foreach (['Hello ', 'World!'] as $key => $word) {
+ $response->chunk($word, $key == 1);
+ }
+ });
+
+Http::get('/redirect')
+ ->inject('response')
+ ->action(function (Response $response) {
+ $response->redirect('/');
+ });
+
+Http::get('/humans.txt')
+ ->inject('response')
+ ->action(function (Response $response) {
+ $response->noContent();
+ });
+
+Http::post('/functions/deployment')
+ ->alias('/functions/deployment/:deploymentId')
+ ->param('deploymentId', '', new Text(64, 0), '', true)
+ ->inject('response')
+ ->action(function (string $deploymentId, Response $response) {
+ if (empty($deploymentId)) {
+ $response->noContent();
+ return;
+ }
+
+ $response->send('ID:' . $deploymentId);
+ });
+
+Http::post('/databases/:databaseId/collections/:collectionId')
+ ->alias('/database/collections/:collectionId')
+ ->param('databaseId', '', new Text(64, 0), '', true)
+ ->param('collectionId', '', new Text(64, 0), '', true)
+ ->inject('response')
+ ->action(function (string $databaseId, string $collectionId, Response $response) {
+ $response->send($databaseId . ';' . $collectionId);
+ });
+
+// Endpoints for early response
+// Meant to run twice, so init hook can know if action ran
+$earlyResponseAction = 'no';
+Http::init()
+ ->groups(['early-response'])
+ ->inject('response')
+ ->action(function (Response $response) use ($earlyResponseAction) {
+ $response->send('Init response. Actioned before: ' . $earlyResponseAction);
+ });
+
+Http::get('/early-response')
+ ->groups(['early-response'])
+ ->inject('response')
+ ->action(function (Response $response) use (&$earlyResponseAction) {
+ $earlyResponseAction = 'yes';
+ $response->send('Action response');
+ });
+
+// ── Error handler (required for Swoole to close the connection on 404) ──
+
+Http::error()
+ ->inject('error')
+ ->inject('response')
+ ->action(function (\Throwable $error, Response $response) {
+ $response
+ ->setStatusCode($error->getCode() ?: 500)
+ ->send('');
+ });
+
+// ── Streaming endpoints ──
+
+Http::get('/stream/generator')
+ ->inject('response')
+ ->action(function (Response $response) {
+ $chunks = ['chunk1-', 'chunk2-', 'chunk3'];
+ $totalSize = array_sum(array_map('strlen', $chunks));
+
+ $generator = (function () use ($chunks) {
+ foreach ($chunks as $chunk) {
+ yield $chunk;
+ }
+ })();
+
+ $response->stream($generator, $totalSize);
+ });
+
+Http::get('/stream/callable')
+ ->inject('response')
+ ->action(function (Response $response) {
+ $data = str_repeat('A', 1000);
+
+ $response->stream(function (int $offset, int $length) use ($data) {
+ return substr($data, $offset, $length);
+ }, strlen($data));
+ });
+
+Http::get('/stream/generator-large')
+ ->inject('response')
+ ->action(function (Response $response) {
+ $chunkSize = 100000; // 100KB per chunk
+ $numChunks = 5;
+ $totalSize = $chunkSize * $numChunks;
+
+ $generator = (function () use ($chunkSize, $numChunks) {
+ for ($i = 0; $i < $numChunks; $i++) {
+ yield str_repeat(chr(65 + $i), $chunkSize);
+ }
+ })();
+
+ $response->stream($generator, $totalSize);
+ });
diff --git a/tests/e2e/server.php b/tests/e2e/server.php
index 59404c2..1876213 100644
--- a/tests/e2e/server.php
+++ b/tests/e2e/server.php
@@ -1,11 +1,10 @@
inject('response')
- ->action(function (Response $response) {
- $response->send('Hello World!');
- });
-
-Http::get('/value/:value')
- ->param('value', '', new Text(64))
- ->inject('response')
- ->action(function (string $value, Response $response) {
- $response->send($value);
- });
-
-Http::get('/cookies')
- ->inject('request')
- ->inject('response')
- ->action(function (Request $request, Response $response) {
- $response->send($request->getHeaders()['cookie'] ?? '');
- });
-
-Http::get('/set-cookie')
- ->inject('request')
- ->inject('response')
- ->action(function (Request $request, Response $response) {
- $response->addHeader('Set-Cookie', 'key1=value1');
- $response->addHeader('Set-Cookie', 'key2=value2');
- $response->send('OK');
- });
-
-Http::get('/set-cookie-no-override')
- ->inject('request')
- ->inject('response')
- ->action(function (Request $request, Response $response) {
- $response->addHeader('Set-Cookie', 'key1=value1', override: false);
- $response->addHeader('Set-Cookie', 'key2=value2', override: false);
- $response->send('OK');
- });
-
-Http::get('/chunked')
- ->inject('response')
- ->action(function (Response $response) {
- foreach (['Hello ', 'World!'] as $key => $word) {
- $response->chunk($word, $key == 1);
- }
- });
-
-Http::get('/redirect')
- ->inject('response')
- ->action(function (Response $response) {
- $response->redirect('/');
- });
-
-Http::get('/humans.txt')
- ->inject('response')
- ->action(function (Response $response) {
- $response->noContent();
- });
-
-Http::post('/functions/deployment')
- ->alias('/functions/deployment/:deploymentId')
- ->param('deploymentId', '', new Text(64, 0), '', true)
- ->inject('response')
- ->action(function (string $deploymentId, Response $response) {
- if (empty($deploymentId)) {
- $response->noContent();
- return;
- }
-
- $response->send('ID:' . $deploymentId);
- });
-
-Http::post('/databases/:databaseId/collections/:collectionId')
- ->alias('/database/collections/:collectionId')
- ->param('databaseId', '', new Text(64, 0), '', true)
- ->param('collectionId', '', new Text(64, 0), '', true)
- ->inject('response')
- ->action(function (string $databaseId, string $collectionId, Response $response) {
- $response->send($databaseId . ';' . $collectionId);
- });
-
-// Endpoints for early response
-// Meant to run twice, so init hook can know if action ran
-$earlyResponseAction = 'no';
-Http::init()
- ->groups(['early-response'])
- ->inject('response')
- ->action(function (Response $response) use ($earlyResponseAction) {
- $response->send('Init response. Actioned before: ' . $earlyResponseAction);
- });
-
-Http::get('/early-response')
- ->groups(['early-response'])
- ->inject('response')
- ->action(function (Response $response) use (&$earlyResponseAction) {
- $earlyResponseAction = 'yes';
- $response->send('Action response');
- });
+require_once __DIR__ . '/routes.php';
$request = new Request();
$response = new Response();
diff --git a/tests/e2e/swoole-server.php b/tests/e2e/swoole-server.php
new file mode 100644
index 0000000..69cce74
--- /dev/null
+++ b/tests/e2e/swoole-server.php
@@ -0,0 +1,60 @@
+inject('response')
+ ->action(function (Response $response) {
+ /** @var SwooleResponse $response */
+ $response->setDetach(false);
+
+ $chunks = ['nd-chunk1-', 'nd-chunk2-', 'nd-chunk3'];
+ $totalSize = array_sum(array_map('strlen', $chunks));
+
+ $generator = (function () use ($chunks) {
+ foreach ($chunks as $chunk) {
+ yield $chunk;
+ }
+ })();
+
+ $response->stream($generator, $totalSize);
+ });
+
+Http::get('/stream/non-detach/callable')
+ ->inject('response')
+ ->action(function (Response $response) {
+ /** @var SwooleResponse $response */
+ $response->setDetach(false);
+
+ $data = str_repeat('B', 1000);
+
+ $response->stream(function (int $offset, int $length) use ($data) {
+ return substr($data, $offset, $length);
+ }, strlen($data));
+ });
+
+$server = new Server('0.0.0.0', '80');
+$app = new Http('UTC');
+$app->setCompression(true);
+$server->onStart(function () {
+ echo 'Swoole server started on port 80' . PHP_EOL;
+});
+$server->onRequest(function (Request $request, SwooleResponse $response) use ($app) {
+ $app->run($request, $response);
+});
+$server->start();