Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,8 @@ jobs:
- name: Wait for Server to be ready
run: sleep 10

- name: Run Tests
run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml
- name: Run FPM Tests
run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite default

- name: Run Swoole Tests
run: docker compose exec swoole vendor/bin/phpunit --configuration phpunit.xml --testsuite swoole
25 changes: 25 additions & 0 deletions Dockerfile.swoole
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM composer:2.0 AS step0

ARG TESTING=true
ENV TESTING=$TESTING

WORKDIR /usr/local/src/

COPY composer.* /usr/local/src/

RUN composer install --ignore-platform-reqs --optimize-autoloader \
--no-plugins --no-scripts --prefer-dist \
`if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi`

FROM appwrite/utopia-base:php-8.4-1.0.0 AS final

WORKDIR /usr/share/nginx/html

COPY ./src /usr/share/nginx/html/src
COPY ./tests /usr/share/nginx/html/tests
COPY ./phpunit.xml /usr/share/nginx/html/phpunit.xml
COPY --from=step0 /usr/local/src/vendor /usr/share/nginx/html/vendor

EXPOSE 8080

CMD ["php", "tests/e2e/server-swoole.php"]
11 changes: 10 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,13 @@ services:
- "9020:80"
volumes:
- ./src:/usr/share/nginx/html/src
- ./tests:/usr/share/nginx/html/tests
- ./tests:/usr/share/nginx/html/tests
swoole:
build:
context: .
dockerfile: Dockerfile.swoole
ports:
- "9021:8080"
volumes:
- ./src:/usr/share/nginx/html/src
- ./tests:/usr/share/nginx/html/tests
11 changes: 9 additions & 2 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@
stopOnFailure="false"
>
<testsuites>
<testsuite name="Application Test Suite">
<testsuite name="default">
<file>./tests/e2e/Client.php</file>
<directory>./tests/</directory>
<exclude>./tests/SwooleResponseTest.php</exclude>
<exclude>./tests/e2e/SwooleResponseTest.php</exclude>
</testsuite>
<testsuite name="swoole">
<file>./tests/e2e/Client.php</file>
<file>./tests/SwooleResponseTest.php</file>
<file>./tests/e2e/SwooleResponseTest.php</file>
</testsuite>
</testsuites>
</phpunit>
</phpunit>
135 changes: 135 additions & 0 deletions src/Http/Adapter/Swoole/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +15,11 @@ class Response extends UtopiaResponse
*/
protected SwooleResponse $swoole;

/**
* Swoole HTTP Server for raw TCP sends after detach().
*/
protected ?SwooleServer $server = null;

/**
* Response constructor.
*/
Expand All @@ -23,6 +29,17 @@ public function __construct(SwooleResponse $response)
parent::__construct(\microtime(true));
}

/**
* Set the Swoole HTTP Server instance.
*
* Required for stream() to use detach() + server->send() for
* sending responses with Content-Length and streaming body.
*/
public function setSwooleServer(SwooleServer $server): void
{
$this->server = $server;
}

/**
* Write
*
Expand All @@ -45,6 +62,124 @@ public function end(?string $content = null): void
$this->swoole->end($content);
}

/**
* Stream a large response body with Content-Length.
*
* Overrides the base implementation to use Swoole's detach() +
* $server->send() pattern. This bypasses Swoole's forced chunked
* Transfer-Encoding, allowing Content-Length to be sent with a
* streaming body so browsers can show download progress.
*
* @param callable(int, int): string $reader fn($offset, $length) returns chunk data
* @param int $totalSize Total response body size in bytes
*/
public function stream(callable $reader, int $totalSize): void
{
if ($this->sent) {
return;
}

// Fallback to base implementation if server not available
if ($this->server === null) {
parent::stream($reader, $totalSize);
return;
}

$this->sent = true;

if ($this->disablePayload) {
$this->appendCookies()->appendHeaders();
$this->end();
return;
}

// Build raw HTTP response with Content-Length
$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);

$serverHeader = $this->headers['Server'] ?? 'Utopia/Http';
$this->addHeader('Server', $serverHeader, override: true);

if (!empty($this->contentType)) {
$this->addHeader('Content-Type', $this->contentType, override: true);
}

$statusCode = $this->getStatusCode();
$reason = $this->statusCodes[$statusCode] ?? 'Unknown';
$raw = "HTTP/1.1 {$statusCode} {$reason}\r\n";

foreach ($this->headers as $key => $value) {
if (\is_array($value)) {
foreach ($value as $v) {
$raw .= "{$key}: {$v}\r\n";
}
} else {
$raw .= "{$key}: {$value}\r\n";
}
}

foreach ($this->cookies as $cookie) {
$raw .= 'Set-Cookie: ' . $this->buildSetCookieHeader($cookie) . "\r\n";
}

$raw .= "\r\n";

// Detach from Swoole's HTTP layer and send raw TCP
$fd = $this->swoole->fd;
$this->swoole->detach();

if ($this->server->send($fd, $raw) === false) {
$this->server->close($fd);
$this->disablePayload();
return;
}

// Stream body in 2MB chunks
$chunkSize = 2 * 1024 * 1024;
for ($offset = 0; $offset < $totalSize; $offset += $chunkSize) {
$length = \min($chunkSize, $totalSize - $offset);
$data = $reader($offset, $length);
if ($this->server->send($fd, $data) === false) {
break;
}
unset($data);
}

$this->server->close($fd);
$this->disablePayload();
}

/**
* Build a Set-Cookie header string from a cookie array.
*/
private function buildSetCookieHeader(array $cookie): string
{
$parts = [\urlencode($cookie['name']) . '=' . \urlencode($cookie['value'] ?? '')];

if (!empty($cookie['expire'])) {
$parts[] = 'Expires=' . \gmdate('D, d M Y H:i:s T', $cookie['expire']);
$parts[] = 'Max-Age=' . \max(0, $cookie['expire'] - \time());
}
if (!empty($cookie['path'])) {
$parts[] = 'Path=' . $cookie['path'];
}
if (!empty($cookie['domain'])) {
$parts[] = 'Domain=' . $cookie['domain'];
}
if (!empty($cookie['secure'])) {
$parts[] = 'Secure';
}
if (!empty($cookie['httponly'])) {
$parts[] = 'HttpOnly';
}
if (!empty($cookie['samesite'])) {
$parts[] = 'SameSite=' . $cookie['samesite'];
}

return \implode('; ', $parts);
}

/**
* Get status code reason
*
Expand Down
5 changes: 4 additions & 1 deletion src/Http/Adapter/Swoole/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ 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->setSwooleServer($this->server);

call_user_func($callback, new Request($request), $utopiaResponse);
});
}

Expand Down
54 changes: 51 additions & 3 deletions src/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@ abstract class Response
*/
protected bool $sent = false;

/**
* Whether headers have been flushed for a chunked response.
*/
protected bool $chunking = false;

/**
* @var array<string, string|array<string>>
*/
Expand Down Expand Up @@ -777,9 +782,12 @@ public function chunk(string $body = '', bool $end = false): void

$this->addHeader('X-Debug-Speed', (string) (microtime(true) - $this->startTime), override: true);

$this
->appendCookies()
->appendHeaders();
if (!$this->chunking) {
$this->chunking = true;
$this
->appendCookies()
->appendHeaders();
}

if (!$this->disablePayload) {
$this->write($body);
Expand All @@ -792,6 +800,46 @@ public function chunk(string $body = '', bool $end = false): void
}
}

/**
* Stream a large response body with Content-Length.
*
* Sends headers (including Content-Length) then streams the body
* by reading chunks from the provided callback. Adapters may
* override this to use transport-specific optimizations.
*
* @param callable(int, int): string $reader fn($offset, $length) returns chunk data
* @param int $totalSize Total response body size in bytes
*/
public function stream(callable $reader, int $totalSize): void
{
if ($this->sent) {
return;
}
$this->sent = true;

$this->addHeader('Content-Length', (string) $totalSize, override: true);
$this->addHeader('X-Debug-Speed', (string) (microtime(true) - $this->startTime), override: true);
$this->appendCookies()->appendHeaders();

if ($this->disablePayload) {
$this->end();
return;
}

$chunkSize = self::CHUNK_SIZE;
for ($offset = 0; $offset < $totalSize; $offset += $chunkSize) {
$length = \min($chunkSize, $totalSize - $offset);
$data = $reader($offset, $length);
if (!$this->write($data)) {
break;
}
unset($data);
}

$this->end();
$this->disablePayload();
}

/**
* Append headers
*
Expand Down
Loading