If you're like most web developers, you probably associate multipart/form-data encoding with file uploads. What if I told you that the same thing can be done in reverse? Sure, it's slightly less straightforward, but doing so allows us to download several files with one request. Totally worth it, right? The secret, as we'll see here, is the form-data NPM library. By importing it into our node.js project, we can serve all sorts of data to the client, including booleans, strings, and even binary content such as MP3s.
The Server Code
I'm using express, but you can go with any type of server you prefer. You'll want to install the form-data module so that you can use it: npm i form-data
.
Then import it into your server like so:
const FormData = require('form-data');
In your request handler, you'll need to instantiate the FormData object before we can append data to it. For file content, we need to supply the title, content (as a buffer in this case), and the file details. That should include the full file name - including the extension - along with the content type and known length.
app.post("/",
async (req, res) => {
// instantiate the form
const form = new FormData();
const mp3Titles = req.titles;
getAudioAsBuffer(mp3Titles)
.then(buffers => {
buffers.forEach((buffer, i) => {
const title = mp3Titles[i];
form.append(title, buffer, {
filename: `${title}.mp3`,
contentType: 'audio/mpeg',
knownLength: buffer.length,
});
});
writeForm(res, form);
}).catch(error => {
// handle error
});
});
Perhaps the trickiest part is sending the multipart/form-data response. To do that, we need to set some headers via the writeHead() method - most notably the Content-Type and the Content-Length. Luckily, the FormData object has just the methods we need: getBoundary() and getLengthSync() respectively.
Then it's just a matter of writing the form's buffer to the client.
const writeForm = (res, form) => {
// boundary must match the one generated by form-data
res.writeHead(200, {
'Content-Type': `multipart/form-data; boundary=${form.getBoundary()}`,
'Content-Length': form.getLengthSync(),
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Origin': '*',
});
res.write(form.getBuffer());
res.end();
};
The Client-side Code
To make the server call I'm using axios. It returns the response as a promise.
The response.data property is the FormData object. It provides a few methods for obtaining its payload. I used values() which returns an iterator. From there I converted it to an array so that I could inspect its size. Each value should be a Blob instance assuming that all went well. In that case the array is passed along to the downloadBlob() method for processing.
axios
.post(
"http://localhost:5000/",
{
// files to fetch
},
{
headers: { Accept: "multipart/form-data" },
adapter: 'fetch',
responseType: 'formdata'
}
)
.then((response) => {
const values = Array.from(response.data.values());
if (values.length > 0) {
if (values[0] instanceof Blob) {
this.downloadBlob(values);
} else {
//Check for errors
}
}
});
Thanks to the <a> element's download attribute, it's now possible to download a file from the browser. Of course, it's limited to the downloads folder, but it's still extremely useful. Luckily, there are no restrictions regarding the use of JavaScript to either dynamically create the <a> element or to programmatically invoke its onclick event.
To set the download attribute to the Blob, we can use the URL.createObjectURL() method. It can accept a File, image, or any other MediaSource object for which the object URL is to be created. Since we stored each MP3 Blob as a file, we can pass it directly to createObjectURL() without having to apply any further transformations.
Note that most browsers will present a popup dialog asking you to confirm that the page is attempting to download more than one file. Once you click OK, the process should be fully automatic.
downloadBlob = (files) => {
const a = window.document.createElement('a');
a.href = window.URL.createObjectURL(files[0]);
a.download = files[0].name;
// Append anchor to body.
document.body.appendChild(a);
a.click();
// Remove anchor from body
document.body.removeChild(a);
files = files.slice(1);
if (files.length > 0) {
setTimeout(() => this.downloadBlob(files), 200);
}
}
A few things to notice in the above code:
The downloadBlob() method is recursive; it slices all but the first element off the array and proceeds to invoke itself again only if there are any more files to process.
There is a setTimeout() of 200 milliseconds. This is necessary to allow time for the download process to kick off.
A new <a> element is created for every file. It may be possible to recycle the same one by passing it to the downloadBlob() method, but I'll leave it up to you to explore that strategy.
Conclusion
In this article we learned how send multiple files to a client from an express server in a single response using a few NPM libraries. One thing we didn't cover here is how to convert binary file data into a buffer to append to the form. That is a whole other challenge that is best left for a separate article...
Follow me on Substack
Follow me on Bluesky
Connect with me on Linkedin
Top comments (0)