[LiveComponent] Add file downloads from a LiveAction - #3761
Open
smnandre wants to merge 1 commit into
Open
Conversation
Contributor
📊 Packages dist files size differenceThanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
|
||||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR;
Live Components can now trigger a file download from a
LiveActionwithout losing their state: the download happens with the re-render, so anything the action changed is still applied on the page:LiveResponse::downloadUrl()points the browser at a URL it downloads on its own;LiveResponse::downloadFile()sends the file with the response, for content no URL can serve.Why
Returning a file from a
LiveActionhas no supported answer today.The documented workaround is to redirect to a dedicated route, which replaces the render. The download works, but the component never updates.
This picks back up on #2483, which stalled after some time. That PR returned a
BinaryFileResponsefrom the action, and while writing tests for it I hit the reason it could not work: a binary response has nowhere to carrydata-live-props-value.Every
LivePropthe action changed was lost, silently.So the file has to come with the render, not instead of it.
This PR offers two ways to do that.
Introducing
LiveResponseTwo ways to get the file to the browser.
Point the browser at a URL:
LiveResponse::downloadUrl()$urlstringThe action returns a URL. The component renders as usual. The browser downloads the file on its own.
Nothing magic here. The response is a normal render plus one header.
The download is a native one: no memory used, a progress bar, range requests, resuming. And the URL is yours, so you can access-control it and log it.
Use this whenever a route can serve the file. It covers most cases, pre-signed S3 links included.
I believe this is the one we should point people to first.
Send the file with the response:
LiveResponse::downloadFile()$contentstring,\SplFileInfo,resource,\Closurestringis the contents, never a path. A closure echoes them or returns an iterable.$filenamestring\SplFileInfo: its basename is used.$contentTypestringapplication/octet-stream. Never guessed from the content.$sizeintstringand an\SplFileInfo. Pass it for a stream or a closure to get aContent-Length, and a progress bar.Sometimes no URL can serve the content. The action builds it, and exposing it would mean storing it first. That is what most requests on this topic are about.
The response then carries both the render and the file, one after the other, with a header saying where to cut.
Everything but a
stringis streamed, so the file never sits in memory on the server.This one will not please everyone. The browser buffers the file before saving it. It fits reports and exports, not huge archives. That is why the docs point to
downloadUrl()first.It does work though, and the format can grow. The offset is known before the body is read. So the client could later stream it with a reader instead of buffering, report progress, or write straight to disk once the File System Access API is everywhere. None of that touches the server side.
How it works
LiveComponentSubscriberreads the directive fromgetControllerResult()inonKernelView.LiveResponsedeliberately does not extendResponse: if it did,onKernelViewwould never run and there would be no render at all, which is the original problem.For
downloadUrl(), the render is untouched and carries anX-Live-Download-Urlheader.For
downloadFile(), the file is appended to the HTML in the same body, with the byte offset where the HTML ends:Length-prefixed rather than multipart: the server knows both sizes when it writes, so an offset says strictly more than a delimiter, with no escaping to worry about, and it still works with a global
Content-Length.The frontend splits on bytes and decodes only the HTML side, so a file that is not valid UTF-8 survives. The filename is percent-encoded in a header, which sidesteps the RFC 5987
Content-Dispositionhandling that was the blocking bug in #2483.The
Content-Typestays the usual vendor type, so the existing frontend check is untouched.Notes
Guard rails
A
LiveResponsecan only be returned from aLiveActionor aLiveListener, over POST.Returning one from the default action throws: that action runs on every re-render, so a component with
data-pollwould fire a download every few hundred milliseconds. Returning one from a GET throws too, since a GET is meant to be replayable by prefetching or crawling.This is not only our rule. Browsers already treat a download as something the user asked for: they block automatic ones and prompt before a second file in a row. Our trick, a hidden link clicked from JS, works today because a click started the whole thing, but it is not something a spec promises us. If that side moves, it will get stricter, not looser. So a download that does not follow a real user action may work today and stop working tomorrow, whatever we do here.
downloadFile()also rejects a$sizethat contradicts the real one (an inexactContent-Lengthtruncates the response), a$contentTypecontaining a line break, a missing filename, and an unsupported content type.An action returns either a
LiveResponseor a redirect, never both, so that combination is impossible by construction rather than something to detect.Design decisions
BatchActionControllercarries the directive up to the final render. Unlike a redirect, a download does not interrupt the batch; the last one wins.is_file()would make behaviour depend on the filesystem and would turn any user-supplied string into an arbitrary file read. The path goes through\SplFileInfo, so the type carries the intent.symfony/mimeis not a dependency, so$contentTypedefaults toapplication/octet-streamrather than being inferred.streamFile()design and the filename handling come from @kbond's work on [LiveComponent] Add support for downloading files from LiveActions (Experimental) #2483.Next steps
Documentation is included in this PR.
A demo for the website is almost ready and will follow in its own PR.
Same with the
apps/e2etests: getting those to run locally gave me enough dependency headaches that I left them out of this one 😅