File uploads in FeathersJS

Over the last months we at ciancoders.com have been working in a new SPA project using Feathers and React, the combination of those two turns out to be just amazing.

Recently we were struggling to find a way to upload files without having to write a separate Express middleware or having to (re)write a complex Feathers service.

Our Goals

We want to implement an upload service to accomplish a few important things:

  1. It has to handle large files (+10MB).
  2. It needs to work with the app’s authentication and authorization.
  3. The files need to be validated.
  4. At the moment there is no third party storage service involved, but this will change in the near future, so it has to be prepared.
  5. It has to show the upload progress.

The plan is to upload the files to a feathers service so we can take advantage of hooks for authentication, authorization and validation, and for service events.

Fortunately, there exists a file storage service: feathers-blob. With it we can meet our goals, but (spoiler alert) it isn’t an ideal solution. We discuss some of its problems below.

Basic upload with feathers-blob and feathers-client

For the sake of simplicity, we will be working over a very basic feathers server, with just the upload service.

Lets look at the server code:

  1. /* --- server.js --- */
  2. const feathers = require('@feathersjs/feathers');
  3. const express = require('@feathersjs/express');
  4. const socketio = require('feathers-socketio');
  5. // feathers-blob service
  6. const blobService = require('feathers-blob');
  7. // Here we initialize a FileSystem storage,
  8. // but you can use feathers-blob with any other
  9. // storage service like AWS or Google Drive.
  10. const fs = require('fs-blob-store');
  11. const blobStorage = fs(__dirname + '/uploads');
  12. // Feathers app
  13. const app = express(feathers());
  14. // Parse HTTP JSON bodies
  15. app.use(express.json());
  16. // Parse URL-encoded params
  17. app.use(express.urlencoded({ extended: true }));
  18. // Add REST API support
  19. app.configure(express.rest());
  20. // Configure Socket.io real-time APIs
  21. app.configure(socketio());
  22. // Upload Service
  23. app.use('/uploads', blobService({Model: blobStorage}));
  24. // Register a nicer error handler than the default Express one
  25. app.use(express.errorHandler());
  26. // Start the server
  27. app.listen(3030, function(){
  28. console.log('Feathers app started at localhost:3030')
  29. });

Let’s look at this implemented in the @feathersjs/cli generated server code:

  1. /* --- /src/services/uploads/uploads.service.js --- */
  2. // Initializes the `uploads` service on path `/uploads'
  3. // Here we used the nedb database, but you can
  4. // use any other ORM database.
  5. const createService = require('feathers-nedb');
  6. const createModel = require('../../models/uploads.model');
  7. const hooks = require('./uploads.hooks');
  8. const filters = require('./uploads.filters');
  9. // feathers-blob service
  10. const blobService = require('feathers-blob');
  11. // Here we initialize a FileSystem storage,
  12. // but you can use feathers-blob with any other
  13. // storage service like AWS or Google Drive.
  14. const fs = require('fs-blob-store');
  15. // File storage location. Folder must be created before upload.
  16. // Example: './uploads' will be located under feathers app top level.
  17. const blobStorage = fs('./uploads');
  18. module.exports = function() {
  19. const app = this;
  20. const Model = createModel(app);
  21. const paginate = app.get('paginate');
  22. // Initialize our service with any options it requires
  23. app.use('/uploads', blobService({ Model: blobStorage}));
  24. // Get our initialized service so that we can register hooks and filters
  25. const service = app.service('uploads');
  26. service.hooks(hooks);
  27. if (service.filter) {
  28. service.filter(filters);
  29. }
  30. };

feathers-blob works over abstract-blob-store, which is an abstract interface to various storage backends, such as filesystem, AWS, or Google Drive. It only accepts and retrieves files encoded as dataURI strings.

Just like that we have our backend ready, go ahead and POST something to localhost:3030/uploads`, for example with postman:

  1. {
  2. 'uri': 'data:image/gif;base64,R0lGODlhEwATAPcAAP/+//7/////+////fvzYvryYvvzZ/fxg/zxWfvxW/zwXPrtW/vxXvfrXv3xYvrvYvntYvnvY/ruZPrwZPfsZPjsZfjtZvfsZvHmY/zxavftaPrvavjuafzxbfnua/jta/ftbP3yb/zzcPvwb/zzcfvxcfzxc/3zdf3zdv70efvwd/rwd/vwefftd/3yfPvxfP70f/zzfvnwffvzf/rxf/rxgPjvgPjvgfnwhPvzhvjvhv71jfz0kPrykvz0mv72nvblTPnnUPjoUPrpUvnnUfnpUvXlUfnpU/npVPnqVPfnU/3uVvvsWPfpVvnqWfrrXPLiW/nrX/vtYv7xavrta/Hlcvnuf/Pphvbsif3zk/zzlPzylfjuk/z0o/LqnvbhSPbhSfjiS/jlS/jjTPfhTfjlTubUU+/iiPPokfrvl/Dll/ftovLWPfHXPvHZP/PbQ/bcRuDJP/PaRvjgSffdSe3ddu7fge7fi+zkuO7NMvPTOt2/Nu7SO+3OO/PWQdnGbOneqeneqvDqyu3JMuvJMu7KNfHNON7GZdnEbejanObXnOW8JOa9KOvCLOnBK9+4Ku3FL9ayKuzEMcenK9e+XODOiePSkODOkOW3ItisI9yxL+a9NtGiHr+VH5h5JsSfNM2bGN6rMJt4JMOYL5h4JZl5Jph3Jpl4J5h5J5h3KJl4KZp5Ks+sUN7Gi96lLL+PKMmbMZt2Jpp3Jpt3KZl4K7qFFdyiKdufKsedRdm7feOpQN2QKMKENrpvJbFfIrNjJL1mLMBpLr9oLrFhK69bJFkpE1kpFYNeTqFEIlsoFbmlnlsmFFwpGFkoF/////7+/v///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAANAALAAAAAATABMAAAj/AKEJHCgokKJKlhThGciQYSIva7r8SHPFzqGGAwPd4bKlh5YsPKy0qFLnT0NAaHTcsIHDho0aKkaAwGCGEkM1NmSkIjWLBosVJT6cOjUrzsBKPl54KmYsACoTMmk1WwaA1CRoeM7siJEqmTIAsjp40ICK2bEApfZcsoQlxwxRzgI8W8XhgoVYA+Kq6sMK0QEYKVCUkoVqQwQJFTwFEAAAFZ9PlFy4OEEiRIYJD55EodDA1ClTbPp0okRFxBQDBRgskAKhiRMlc+Sw4SNpFCIoBBwkUMBkCBIiY8qAgcPG0KBHrBTFQbCEV5EjQYQACfNFjp5CgxpxagVtUhIjwzaJYSHzhQ4cP3ryQHLEqJbASnu+6EIW6o2b2X0ISXK0CFSugazs0YYmwQhziyuE2PLLIv3h0hArkRhiCCzAENOLL7tgAoqDGLXSSSaPMLIIJpmAUst/GA3UCiuv1PIKLtw1FBAAOw=='
  3. }

The service will respond with something like this:

  1. {
  2. 'id': '6454364d8facd7a88e627e4c4b11b032d2f83af8f7f9329ffc2b7a5c879dc838.gif',
  3. 'uri': 'the-same-uri-we-uploaded',
  4. 'size': 1156
  5. }

Or we can implement a very basic frontend with feathers-client and jQuery:

  1. <!doctype html>
  2. <html>
  3. <head>
  4. <title>Feathersjs File Upload</title>
  5. <script src='https://code.jquery.com/jquery-2.2.3.min.js' integrity='sha256-a23g1Nt4dtEYOj7bR+vTu7+T8VP13humZFBJNIYoEJo=' crossorigin='anonymous'></script>
  6. <script type='text/javascript' src='//cdnjs.cloudflare.com/ajax/libs/core-js/2.1.4/core.min.js'></script>
  7. <script type='text/javascript' src='//unpkg.com/feathers-client@^2.0.0/dist/feathers.js'></script>
  8. <script type='text/javascript'>
  9. // feathers client initialization
  10. const rest = feathers.rest('http://localhost:3030');
  11. const app = feathers()
  12. .configure(feathers.hooks())
  13. .configure(rest.jquery($));
  14. // setup jQuery to watch the ajax progress
  15. $.ajaxSetup({
  16. xhr: function () {
  17. var xhr = new window.XMLHttpRequest();
  18. // upload progress
  19. xhr.addEventListener('progress', function (evt) {
  20. if (evt.lengthComputable) {
  21. var percentComplete = evt.loaded / evt.total;
  22. console.log('upload progress: ', Math.round(percentComplete * 100) + '%');
  23. }
  24. }, false);
  25. return xhr;
  26. }
  27. });
  28. const uploadService = app.service('uploads');
  29. const reader = new FileReader();
  30. // encode selected files
  31. $(document).ready(function(){
  32. $('input#file').change(function(){
  33. var file = this.files[0];
  34. // encode dataURI
  35. reader.readAsDataURL(file);
  36. })
  37. });
  38. // when encoded, upload
  39. reader.addEventListener('load', function () {
  40. console.log('encoded file: ', reader.result);
  41. var upload = uploadService
  42. .create({uri: reader.result})
  43. .then(function(response){
  44. // success
  45. alert('UPLOADED!! ');
  46. console.log('Server responded with: ', response);
  47. });
  48. }, false);
  49. </script>
  50. </head>
  51. <body>
  52. <h1>Let's upload some files!</h1>
  53. <input type='file' id='file'/>
  54. </body>
  55. </html>

This code watches for file selection, then encodes it and does an ajax post to upload it, watching the upload progress via the xhr object. Everything works as expected.

Every file we select gets uploaded and saved to the ./uploads directory.

Work done!, let’s call it a day, shall we?

… But hey, there is something that doesn’t feels quite right …right?

DataURI upload problems

It doesn’t feels right because it is not. Let’s imagine what would happen if we try to upload a large file, say 25MB or more: The entire file (plus some extra MB due to the encoding) has to be kept in memory for the entire upload process, this could look like nothing for a normal computer but for mobile devices it’s a big deal.

We have a big RAM consumption problem. Not to mention we have to encode the file before sending it…

The solution would be to modify the service, adding support for splitting the dataURI into small chunks, then uploading one at a time, collecting and reassembling everything on the server. But hey, it’s not that the same thing browsers and web servers has been doing since maybe the very early days of the web? maybe since Netscape Navigator?

Well, actually it is, and doing a multipart/form-data post is still the easiest way to upload a file.

Feathers-blob with multipart support.

Back with the backend, in order to accept multipart uploads, we need a way to handle the multipart/form-data received by the web server. Given that Feathers behaves like Express, let’s just use multer and a custom middleware to handle that.

  1. /* --- server.js --- */
  2. const multer = require('multer');
  3. const multipartMiddleware = multer();
  4. // Upload Service with multipart support
  5. app.use('/uploads',
  6. // multer parses the file named 'uri'.
  7. // Without extra params the data is
  8. // temporarely kept in memory
  9. multipartMiddleware.single('uri'),
  10. // another middleware, this time to
  11. // transfer the received file to feathers
  12. function(req,res,next){
  13. req.feathers.file = req.file;
  14. next();
  15. },
  16. blobService({Model: blobStorage})
  17. );

Notice we kept the file field name as uri just to maintain uniformity, as the service will always work with that name anyways. But you can change it if you prefer.

Feathers-blob only understands files encoded as dataURI, so we need to convert them first. Let’s make a Hook for that:

  1. /* --- server.js --- */
  2. const dauria = require('dauria');
  3. // before-create Hook to get the file (if there is any)
  4. // and turn it into a datauri,
  5. // transparently getting feathers-blob to work
  6. // with multipart file uploads
  7. app.service('/uploads').before({
  8. create: [
  9. function(context) {
  10. if (!context.data.uri && context.params.file){
  11. const file = context.params.file;
  12. const uri = dauria.getBase64DataURI(file.buffer, file.mimetype);
  13. context.data = {uri: uri};
  14. }
  15. }
  16. ]
  17. });

Et voilà!. Now we have a FeathersJS file storage service working, with support for traditional multipart uploads, and a variety of storage options to choose.

Simply awesome.

Further improvements

The service always return the dataURI back to us, which may not be necessary as we’d just uploaded the file, also we need to validate the file and check for authorization.

All those things can be easily done with more Hooks, and that’s the benefit of keeping all inside FeathersJS services. I left that to you.

For the frontend, there is a problem with the client: in order to show the upload progress it’s stuck with only REST functionality and not real-time with socket.io.

The solution is to switch feathers-client from REST to socket.io, and just use wherever you like for uploading the files, that’s an easy task now that we are able to do a traditional form-multipart upload.

Here is an example using dropzone:

  1. <!doctype html>
  2. <html>
  3. <head>
  4. <title>Feathersjs File Upload</title>
  5. <link rel='stylesheet' href='assets/dropzone.css'>
  6. <script src='assets/dropzone.js'></script>
  7. <script type='text/javascript' src='socket.io/socket.io.js'></script>
  8. <script type='text/javascript' src='//cdnjs.cloudflare.com/ajax/libs/core-js/2.1.4/core.min.js'></script>
  9. <script type='text/javascript' src='//unpkg.com/feathers-client@^2.0.0/dist/feathers.js'></script>
  10. <script type='text/javascript'>
  11. // feathers client initialization
  12. var socket = io('http://localhost:3030');
  13. const app = feathers()
  14. .configure(feathers.hooks())
  15. .configure(feathers.socketio(socket));
  16. const uploadService = app.service('uploads');
  17. // Now with Real-Time Support!
  18. uploadService.on('created', function(file){
  19. alert('Received file created event!', file);
  20. });
  21. // Let's use DropZone!
  22. Dropzone.options.myAwesomeDropzone = {
  23. paramName: 'uri',
  24. uploadMultiple: false,
  25. init: function(){
  26. this.on('uploadprogress', function(file, progress){
  27. console.log('progresss', progress);
  28. });
  29. }
  30. };
  31. </script>
  32. </head>
  33. <body>
  34. <h1>Let's upload some files!</h1>
  35. <form action='/uploads'
  36. class='dropzone'
  37. id='my-awesome-dropzone'></form>
  38. </body>
  39. </html>

All the code is available via github here: https://github.com/CianCoders/feathers-example-fileupload

Hope you have learned something today, as I learned a lot writing this.

Cheers!