Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable file streaming #122

Closed
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
2 changes: 1 addition & 1 deletion .rr.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ server:

http:
address: 0.0.0.0:8080
middleware: [ "static", "gzip" ]
middleware: [ "sendfile", "gzip", "static" ]
pool:
debug: true
uploads:
Expand Down
2 changes: 1 addition & 1 deletion .rr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ server:

http:
address: 0.0.0.0:8080
middleware: [ "sendfile", "gzip", "static" ]
pool:
debug: false
middleware: [ "static", "gzip" ]
uploads:
forbid: [ ".php", ".exe", ".bat" ]
static:
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ The following events are dispatched throughout the worker lifecycle:
- `Baldinof\RoadRunnerBundle\Event\WorkerExceptionEvent`: Dispatched after encountering an uncaught exception during request handling.
- `Baldinof\RoadRunnerBundle\Event\WorkerKernelRebootedEvent`: Dispatched after the symfony kernel was rebooted (see Kernel reboots).

## Sending/streaming files/content - StreamedResponse, BinaryFileResponse
Every response needs to be serialized, so we can send it back to RR.
Because of that, the `Symfony\Component\HttpFoundation\StreamedResponse`
can no longer be used (well, it can, but it makes no sense), because we need to load it's content before passing it to RR.
If you need to send big files, you have to either use `Symfony\Component\HttpFoundation\BinaryFileResponse`
or `Baldinof\RoadRunnerBundle\Response\StreamableFileResponse`. The `BinaryFileResponse` is
automatically converted to bundle's `StreamableFileResponse`. Files are no longer loaded in memory before sending and
and the streaming part is now handled by RR. If you need for any reason to serve files without streaming, use
`Baldinof\RoadRunnerBundle\Response\NonStreamableBinaryFileResponse`, but be aware that the whole file content
is loaded into memory, so make sure you have set your memory limit high enough.

## Development mode

Copy the dev config file if it's not present: `cp vendor/baldinof/roadrunner-bundle/.rr.dev.yaml .`
Expand All @@ -131,10 +142,15 @@ Start RoadRunner with the dev config file:
bin/rr serve -c .rr.dev.yaml
```

Reference: https://roadrunner.dev/docs/beep-beep-reload
Reference: https://roadrunner.dev/docs/php-developer/current

If you use the Symfony VarDumper, dumps will not be shown in the HTTP Response body. You can view dumps with `bin/console server:dump` or in the profiler.

## RoadRunner yaml configs
Bundle expects you to use `.rr.yaml` for `prod` environment and then for others environments it uses `APP_ENV`. For example the `dev` app environment uses `.rr.dev.yaml`
or `test` would use `.rr.test.yaml`. If you need to use different config naming, set environment variable `RR_CONFIG_NAME`. For example to `.rr.my_custom_name.yaml`.
You can retrieve current RR config by injecting `Baldinof\RoadRunnerBundle\Helpers\RoadRunnerConfig`, more info inside.

## Metrics
Roadrunner can [collect application metrics](https://roadrunner.dev/docs/beep-beep-metrics), and expose a prometheus endpoint.

Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"symfony/config": "^6.0",
"symfony/dependency-injection": "^6.0",
"symfony/http-kernel": "^6.0",
"symfony/mime": "^6.0",
"symfony/yaml": "^6.0",
"spiral/roadrunner": "^2023.1.0",
"spiral/roadrunner-worker": "^3.0.0",
Expand Down
10 changes: 8 additions & 2 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Baldinof\RoadRunnerBundle\DependencyInjection\BaldinofRoadRunnerExtension;
use Baldinof\RoadRunnerBundle\Grpc\GrpcServiceProvider;
use Baldinof\RoadRunnerBundle\Helpers\RoadRunnerConfig;
use Baldinof\RoadRunnerBundle\Helpers\RPCFactory;
use Baldinof\RoadRunnerBundle\Http\KernelHandler;
use Baldinof\RoadRunnerBundle\Http\MiddlewareStack;
Expand All @@ -31,7 +32,6 @@
use Spiral\RoadRunner\Metrics\MetricsInterface;
use Spiral\RoadRunner\Worker as RoadRunnerWorker;
use Spiral\RoadRunner\WorkerInterface as RoadRunnerWorkerInterface;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

return static function (ContainerConfigurator $container) {
Expand Down Expand Up @@ -59,8 +59,14 @@
->args([service(RPCInterface::class)]);

// Bundle services
$services->set(RoadRunnerConfig::class)
->args([param('kernel.project_dir')]);

$services->set(HttpFoundationWorkerInterface::class, HttpFoundationWorker::class)
->args([service(HttpWorkerInterface::class)]);
->args([
service(HttpWorkerInterface::class),
service(RoadRunnerConfig::class),
]);

$services->set(WorkerRegistryInterface::class, WorkerRegistry::class)
->public();
Expand Down
9 changes: 9 additions & 0 deletions src/Exception/MissingHttpMiddlewareException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Baldinof\RoadRunnerBundle\Exception;

class MissingHttpMiddlewareException extends \RuntimeException implements ExceptionInterface
{
}
17 changes: 17 additions & 0 deletions src/Exception/RoadRunnerConfigYamlNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Baldinof\RoadRunnerBundle\Exception;

class RoadRunnerConfigYamlNotFoundException extends \RuntimeException implements ExceptionInterface
{
public function __construct(string $path)
{
parent::__construct(sprintf(
"Expected to find RR config at '%s', but file appears to not exist. Perhaps you want to explicitly set '%s' environment variable to match the config name you are using",
$path,
'RR_CONFIG_NAME'
));
}
}
26 changes: 26 additions & 0 deletions src/Exception/StreamedResponseNotSupportedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Baldinof\RoadRunnerBundle\Exception;

use Baldinof\RoadRunnerBundle\Response\NonStreamableBinaryFileResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;

class StreamedResponseNotSupportedException extends \RuntimeException implements ExceptionInterface
{
public function __construct(StreamedResponse $streamedResponse, bool $sendFileMiddlewareEnabled)
{
parent::__construct(sprintf(
"'%s' is pointless in context of RoadRunner as the content needs to be fully generated before passing it to RoadRunner, thus losing the streaming part. Use '%s' or '%s'. If you need to send big file, send '%s' only with a file path - it will be streamed using RoadRunner middleware '%s'%s",
$streamedResponse::class,
NonStreamableBinaryFileResponse::class,
Response::class,
BinaryFileResponse::class,
'sendfile',
!$sendFileMiddlewareEnabled ? ', which appears to be disabled, make sure it\'s enabled and the RR has restarted' : ''
));
}
}
15 changes: 15 additions & 0 deletions src/Exception/UnableToReadFileException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Baldinof\RoadRunnerBundle\Exception;

use Symfony\Component\HttpFoundation\BinaryFileResponse;

class UnableToReadFileException extends \RuntimeException implements ExceptionInterface
{
public function __construct(BinaryFileResponse $symfonyResponse, int $code = 0, ?\Throwable $previous = null)
{
parent::__construct(sprintf("Cannot read file '%s'", $symfonyResponse->getFile()->getPathname()), $code, $previous);
}
}
59 changes: 59 additions & 0 deletions src/Helpers/RoadRunnerConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace Baldinof\RoadRunnerBundle\Helpers;

use Baldinof\RoadRunnerBundle\Exception\RoadRunnerConfigYamlNotFoundException;
use Symfony\Component\Yaml\Yaml;

/**
* Be aware that any changes during RR runtime
* will be shown here when worker resets
* but will never be applied
* You need to restart RR.
*/
class RoadRunnerConfig
{
public const HTTP_MIDDLEWARE_SENDFILE = 'sendfile';

private string $projectDir;
private array $config = [];

public function __construct(string $projectDir)
{
$this->projectDir = $projectDir;
$this->parseConfig();
}

public function getConfig(): array
{
return $this->config;
}

public function isHttpMiddlewareEnabled(string $name): bool
{
return \in_array($name, $this->config['http']['middleware'] ?? [], true);
}

private function parseConfig(): void
{
$filename = $_ENV['RR_CONFIG_NAME'] ?? null;
if ($filename === null && $_ENV['APP_ENV'] === 'prod') {
$filename = '.rr.yaml';
}

if ($filename === null) {
$filename = sprintf('.rr.%s.yaml', $_ENV['APP_ENV']);
}

$pathname = $this->projectDir.'/'.$filename;
if (!file_exists($pathname)) {
throw new RoadRunnerConfigYamlNotFoundException($pathname);
}

$config = Yaml::parseFile($pathname);
\assert(\is_array($config));
$this->config = $config;
}
}
17 changes: 17 additions & 0 deletions src/Response/NonStreamableBinaryFileResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Baldinof\RoadRunnerBundle\Response;

use Symfony\Component\HttpFoundation\BinaryFileResponse;

/**
* Make sure the file content can fit
* within you memory limits,
* because it will be loaded in memory
* before sending back to RR.
*/
class NonStreamableBinaryFileResponse extends BinaryFileResponse
{
}
73 changes: 73 additions & 0 deletions src/Response/StreamableFileResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace Baldinof\RoadRunnerBundle\Response;

use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Mime\MimeTypes;

use function Symfony\Component\String\u;

/**
* Sends file path to RR
* which then handles file streaming
* all by itself, the http middleware 'sendfile'
* needs to be enabled.
*/
class StreamableFileResponse extends Response
{
public function __construct(
string $pathname,
string $disposition = ResponseHeaderBag::DISPOSITION_ATTACHMENT,
?string $contentType = null,
?string $filename = null,
?string $filenameFallback = null,
) {
$realpath = realpath($pathname);
parent::__construct(null, 201, $this->getHeaders(
$realpath !== false ? $realpath : $pathname,
$disposition,
$contentType,
$filename,
$filenameFallback,
));
}

public static function fromBinaryFileResponse(BinaryFileResponse $binaryFileResponse): self
{
return new self(
$binaryFileResponse->getFile()->getPathname(),
$binaryFileResponse->headers->get('Content-Disposition', HeaderUtils::DISPOSITION_ATTACHMENT),
$binaryFileResponse->headers->get('Content-Type'),
$binaryFileResponse->getFile()->getFilename(),
);
}

private function getHeaders(
string $pathname,
string $disposition,
?string $contentType,
?string $filename,
?string $filenameFallback = null,
): array {
return [
'Content-Length' => (int) @filesize($pathname),
'Content-Type' => u($contentType ?? MimeTypes::getDefault()->guessMimeType($pathname))->ensureEnd('; charset=UTF-8')->toString(),
'x-sendfile' => $pathname,
'Content-Disposition' => \in_array($disposition, [HeaderUtils::DISPOSITION_ATTACHMENT, HeaderUtils::DISPOSITION_INLINE]) ? HeaderUtils::makeDisposition(
$disposition,
$filename ?? pathinfo($pathname, PATHINFO_BASENAME),
$filenameFallback ?? u(pathinfo($filename ?? $pathname, PATHINFO_FILENAME))
->ascii()
->replace('/', '-')
->append('.')
->append(pathinfo($filename ?? $pathname, PATHINFO_EXTENSION))
->toString()
) : $disposition,
];
}
}
42 changes: 32 additions & 10 deletions src/RoadRunnerBridge/HttpFoundationWorker.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

namespace Baldinof\RoadRunnerBundle\RoadRunnerBridge;

use Baldinof\RoadRunnerBundle\Exception\MissingHttpMiddlewareException;
use Baldinof\RoadRunnerBundle\Exception\StreamedResponseNotSupportedException;
use Baldinof\RoadRunnerBundle\Exception\UnableToReadFileException;
use Baldinof\RoadRunnerBundle\Helpers\RoadRunnerConfig;
use Baldinof\RoadRunnerBundle\Response\NonStreamableBinaryFileResponse;
use Baldinof\RoadRunnerBundle\Response\StreamableFileResponse;
use Spiral\RoadRunner\Http\HttpWorkerInterface;
use Spiral\RoadRunner\Http\Request as RoadRunnerRequest;
use Spiral\RoadRunner\WorkerInterface;
Expand All @@ -16,11 +22,13 @@
final class HttpFoundationWorker implements HttpFoundationWorkerInterface
{
private HttpWorkerInterface $httpWorker;
private RoadRunnerConfig $roadRunnerConfig;
private array $originalServer;

public function __construct(HttpWorkerInterface $httpWorker)
public function __construct(HttpWorkerInterface $httpWorker, RoadRunnerConfig $roadRunnerConfig)
{
$this->httpWorker = $httpWorker;
$this->roadRunnerConfig = $roadRunnerConfig;
$this->originalServer = $_SERVER;
}

Expand All @@ -37,14 +45,22 @@ public function waitRequest(): ?SymfonyRequest

public function respond(SymfonyResponse $symfonyResponse): void
{
if ($symfonyResponse instanceof BinaryFileResponse && !$symfonyResponse->headers->has('Content-Range')) {
$content = file_get_contents($symfonyResponse->getFile()->getPathname());
if ($content === false) {
throw new \RuntimeException(sprintf("Cannot read file '%s'", $symfonyResponse->getFile()->getPathname())); // TODO: custom error
if ($symfonyResponse instanceof StreamedResponse) {
throw new StreamedResponseNotSupportedException($symfonyResponse, $this->roadRunnerConfig->isHttpMiddlewareEnabled(RoadRunnerConfig::HTTP_MIDDLEWARE_SENDFILE));
}

$content = '';
if ($symfonyResponse instanceof NonStreamableBinaryFileResponse) {
if ($symfonyResponse->headers->has('x-sendfile')) {
$symfonyResponse->headers->remove('x-sendfile');
}
} else {
if ($symfonyResponse instanceof StreamedResponse || $symfonyResponse instanceof BinaryFileResponse) {
$content = '';

if (!$symfonyResponse->headers->has('Content-Range')) {
$content = file_get_contents($symfonyResponse->getFile()->getPathname());
if ($content === false) {
throw new UnableToReadFileException($symfonyResponse);
}
} else {
ob_start(function ($buffer) use (&$content) {
$content .= $buffer;

Expand All @@ -53,9 +69,15 @@ public function respond(SymfonyResponse $symfonyResponse): void

$symfonyResponse->sendContent();
ob_end_clean();
} else {
$content = (string) $symfonyResponse->getContent();
}
} elseif ($symfonyResponse instanceof BinaryFileResponse) {
if (!$this->roadRunnerConfig->isHttpMiddlewareEnabled(RoadRunnerConfig::HTTP_MIDDLEWARE_SENDFILE)) {
throw new MissingHttpMiddlewareException(sprintf("You need to enable '%s' http middleware in order to send '%s'. If you do not want to enable this middleware, use '%s' as fallback", RoadRunnerConfig::HTTP_MIDDLEWARE_SENDFILE, $symfonyResponse::class, NonStreamableBinaryFileResponse::class));
}

$symfonyResponse = StreamableFileResponse::fromBinaryFileResponse($symfonyResponse);
} else {
$content = (string) $symfonyResponse->getContent();
}

$headers = $this->stringifyHeaders($symfonyResponse->headers->all());
Expand Down
Loading