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

[LiveComponent] Add support for downloading files from LiveActions (Experimental) #2483

Open
wants to merge 12 commits into
base: 2.x
Choose a base branch
from
2 changes: 2 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## 2.23.0

- Allow configuring the secret used to compute fingerprints and checksums.
- [EXPERIMENTAL] Add `LiveDownloadResponse` and enable file downloads from
a `LiveAction`.

## 2.22.0

Expand Down
1 change: 1 addition & 0 deletions src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export default class {
private body;
constructor(response: Response);
getBody(): Promise<string>;
getBlob(): Promise<Blob>;
}
28 changes: 26 additions & 2 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ class BackendResponse {
}
return this.body;
}
async getBlob() {
return this.response.blob();
}
}

function getElementAsTagText(element) {
Expand Down Expand Up @@ -2119,11 +2122,32 @@ class Component {
this.isRequestPending = false;
this.backendRequest.promise.then(async (response) => {
const backendResponse = new BackendResponse(response);
const html = await backendResponse.getBody();
const headers = backendResponse.response.headers;
for (const input of Object.values(this.pendingFiles)) {
input.value = '';
}
const headers = backendResponse.response.headers;
const contentDisposition = headers.get('Content-Disposition');
const fileResponse = contentDisposition?.match(/^(attachment|inline).*filename="?([^;]+)"?/);
if (fileResponse) {
const blob = await backendResponse.getBlob();
const link = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(blob),
download: fileResponse[2],
style: 'display: none',
target: '_blank',
});
document.body.appendChild(link);
link.click();
setTimeout(() => document.body.removeChild(link), 75);
this.backendRequest = null;
thisPromiseResolve(backendResponse);
if (this.isRequestPending) {
this.isRequestPending = false;
this.performRequest();
}
return response;
}
const html = await backendResponse.getBody();
if (!headers.get('Content-Type')?.includes('application/vnd.live-component+html') &&
!headers.get('X-Live-Redirect')) {
const controls = { displayError: true };
Expand Down
4 changes: 4 additions & 0 deletions src/LiveComponent/assets/src/Backend/BackendResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ export default class {

return this.body;
}

async getBlob(): Promise<Blob> {
return this.response.blob();
}
}
30 changes: 28 additions & 2 deletions src/LiveComponent/assets/src/Component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,41 @@ export default class Component {

this.backendRequest.promise.then(async (response) => {
const backendResponse = new BackendResponse(response);
const html = await backendResponse.getBody();
const headers = backendResponse.response.headers;

// clear sent files inputs
for (const input of Object.values(this.pendingFiles)) {
input.value = '';
}

// File Download
const contentDisposition = headers.get('Content-Disposition');
const fileResponse = contentDisposition?.match(/^(attachment|inline).*filename="?([^;]+)"?/);
if (fileResponse) {
const blob = await backendResponse.getBlob();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends... if you trigger the "click" and the file start downloading I think browser play the "pipe" role here.

But during my test I managed to crashed pretty violently Chrome and Safari multiple times.

const link = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(blob),
download: fileResponse[2],
style: 'display: none',
target: '_blank',
});
document.body.appendChild(link);
link.click();
setTimeout(() => document.body.removeChild(link), 75);

this.backendRequest = null;
thisPromiseResolve(backendResponse);
if (this.isRequestPending) {
this.isRequestPending = false;
this.performRequest();
}

return response;
}

const html = await backendResponse.getBody();

// if the response does not contain a component, render as an error
const headers = backendResponse.response.headers;
if (
!headers.get('Content-Type')?.includes('application/vnd.live-component+html') &&
!headers.get('X-Live-Redirect')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Psr\Container\ContainerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Exception\JsonException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand Down
51 changes: 51 additions & 0 deletions src/LiveComponent/src/LiveResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace Symfony\UX\LiveComponent;

use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse;

/**
* @author Simon André <[email protected]>
* @author Kevin Bond <[email protected]>
*/
final class LiveResponse
{
/**
* @param string|\SplFileInfo $file The file to send as a response
* @param string|null $filename The name of the file to send (defaults to the basename of the file)
* @param string|null $contentType The content type of the file (defaults to `application/octet-stream`)
*/
public static function file(string|\SplFileInfo $file, ?string $filename = null, ?string $contentType = null, ?int $size = null): BinaryFileResponse
{
return new BinaryFileResponse($file, 200, [
'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename ?? basename($file)),
'Content-Type' => $contentType ?? 'application/octet-stream',
'Content-Length' => $size ?? ($file instanceof \SplFileInfo ? $file->getSize() : null),
]);
}

/**
* @param resource|Closure $file The file to stream as a response
* @param string $filename The name of the file to send (defaults to the basename of the file)
* @param string|null $contentType The content type of the file (defaults to `application/octet-stream`)
* @param int|null $size The size of the file
*/
public static function streamFile(mixed $file, string $filename, ?string $contentType = null, ?int $size = null): StreamedResponse
{
if (!is_resource($file) && !$file instanceof \Closure) {
throw new \InvalidArgumentException(sprintf('The file must be a resource or a closure, "%s" given.', get_debug_type($file)));
}

return new StreamedResponse($file instanceof \Closure ? $file(...) : function () use ($file) {
while (!feof($file)) {
echo fread($file, 1024);
}
}, 200, [
'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename),
'Content-Type' => $contentType ?? 'application/octet-stream',
'Content-Length' => $size,
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;

use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\LiveResponse;

/**
* @author Simon André <[email protected]>
*/
#[AsLiveComponent('download_file', template: 'components/download_file.html.twig')]
class DownloadFileComponent
{
use DefaultActionTrait;

private const FILE_DIRECTORY = __DIR__.'/../files/';

#[LiveAction]
public function download(): BinaryFileResponse
{
$file = new \SplFileInfo(self::FILE_DIRECTORY.'/foo.json');

return LiveResponse::file($file);
}

#[LiveAction]
public function generate(): BinaryFileResponse
{
$file = new \SplTempFileObject();
$file->fwrite(file_get_contents(self::FILE_DIRECTORY.'/foo.json'));

return LiveResponse::file($file, 'foo.json', size: 1000);
}

#[LiveAction]
public function heavyFile(#[LiveArg] int $size): BinaryFileResponse
{
$file = new \SplFileInfo(self::FILE_DIRECTORY.'heavy.txt');

$response = LiveResponse::file($file);
$response->headers->set('Content-Length', 10000000); // 10MB
}
}
9 changes: 9 additions & 0 deletions src/LiveComponent/tests/Fixtures/files/foo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Foo</title>
</head>
<body>
<h1>Bar</h1>
</body>
</html>
3 changes: 3 additions & 0 deletions src/LiveComponent/tests/Fixtures/files/foo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"foo": "bar"
}
3 changes: 3 additions & 0 deletions src/LiveComponent/tests/Fixtures/files/foo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Foo

## Bar
1 change: 1 addition & 0 deletions src/LiveComponent/tests/Fixtures/files/test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
text
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div {{ attributes }}>

</div>
Loading
Loading