Introduction
I recently proposed a new feature for our app: exporting some of our tabulated data as a CSV file. I saw this as low-hanging fruit that I could quickly slot in between some higher priority work. We already had this data right there on the client side - how hard could it be? There’s probably a function already built in to the browser that we can just throw a byte array to, and the browser will handle it all for us, right?
Well, that was quite a naive assumption. We use Rust for our frontend projects, leveraging the incredible web_sys crate to expose the browser API to us. However, after scouring the documentation, it turns out web_sys doesn’t expose such a function. Why? Well, because there is no such function within the browser API. By this time I had already committed to the story (“Yeah I can get it done. That’ll be like, a day”) So, what options are left to us?
One other possibility that is available would be to add an endpoint to our backend, and have the frontend issue a request to that URL. But we already have the data here on the frontend, another web request would be redundant. And I am not so sure that would fit into the time that I had available anyway. So I got to work.
The Code
So after investigating and drawing inspiration from many good JS examples, here is a working function that I wrote in Rust:
fn save_byte_array(name: &str, data: &[u8]) -> Result<(), JsValue> {
use web_sys::{Blob, Url, js_sys::Uint8Array};
// Build file data & metadata
let props = BlobPropertyBag::new();
props.set_type("text/csv");
let blob =Blob::new_with_u8_array_sequence_and_options(
&JsValue::from(vec![Uint8Array::new_from_slice(&data)]),
&props,
)?;
// Add the link element
let document = web_sys::window()
.and_then(|w| w.document())
.ok_or(JsValue::null())?;
let link = document.create_element("a")?;
// Set link attributes
let url = Url::create_object_url_with_blob(&blob)?;
link.set_attribute("href", &url)?;
link.set_attribute("download", name)?;
link.dyn_into::<HtmlAnchorElement>()?.click();
Url::revoke_object_url(&url)?;
Ok(())
}
Yes, you’re reading the code right. The way to achieve this is to marshal the byte array into a browser-managed blob object, add a new element to the page that links to the blob, and then simulate a click on it. I couldn’t believe all of this was really necessary when I first saw it, but it makes sense in hindsight. The browser was designed originally as a document viewer, and all of this client-side webapp functionality that we all depend on was resourcefully derived in the aftermath.
Generating the Data
Before we can save anything, we need to actually build the byte array. For CSV we reach for the csv crate, which can write directly to a Vec<u8>:
let mut wtr = csv::Writer::from_writer(vec![]);
for row in &rows {
wtr.write_record(row)?;
}
let bytes = wtr.into_inner()?;
save_byte_array("export.csv", &bytes)?;
Short and sweet. The Writer handles quoting and escaping for you, so there’s no need to roll your own serialisation.
Polish
So is that function done then? Well, there are some improvements to be made. If you’re a particularly observant person, you might notice a possible memory leak. What if, after creating the object url for our blob, one of our web_sys functions fails? To be clear, this is a problem in theory and may not ever occur in practice, but I figured it would be best to guard for this using some good old-fashioned RAII.
/// A temporary uri to a browser-managed blob object. Manages cleaning up of the object when the
/// `ObjectUrl` is dropped.
pub struct ObjectUrl {
uri: String,
}
impl ObjectUrl {
pub fn new(blob: web_sys::Blob) -> Result<ObjectUrl, JsValue> {
let uri: String = web_sys::Url::create_object_url_with_blob(&blob)
.map_err(JsError::from)?;
Ok(ObjectUrl { uri })
}
pub fn uri(&self) -> &str {
&self.uri
}
}
impl Drop for ObjectUrl {
fn drop(&mut self) {
if let Err(report) = web_sys::Url::revoke_object_url(&self.uri)
.map_err(JsError::from)
{
error!("Failed to revoke object URL");
}
}
}
This is leveraging the Drop trait to ensure that the object URL is disposed of when we no longer need it. This type can then be used like so:
let object = ObjectUrl::new(blob)?;
…
link.set_attribute("href", &object.uri())?;
Limitations
A couple of things worth knowing before you ship this. The entire file is assembled in memory as a Vec<u8> before the Blob is created, so very large exports could cause memory pressure. For our use-case this is a non-issue, but it’s worth keeping in mind if you ever need to export something much bigger. The other gotcha is that the download attribute on <a> doesn’t guarantee that the link will be downloaded, just hints the behaviour to the browser. This isn’t an issue in any browsers that we needed for this project, but you might want to test it on mobile platforms if you plan on supporting those.
So there it is. Feel free to use this for CSVs, bitmaps, or plain .txt docs — just swap in the appropriate MIME type.