react-native (Expo) upload file on background

Hope this isn’t too late to be helpful. I’ve been dealing with a variety of expo <-> firebase storage integrations recently, and here’s some info that might be helpful.

First, I’d recommend not using the uploadBytes / uploadBytesResumable methods from Firebase This Thread has a long ongoing discussion about it, but basically it’s broken in v9. Maybe in the future the Firebase team will solve the issues, but it’s pretty broken with Expo right now.

Instead, I’d recommend either going down the route of writing a small Firebase function that either gives a signed-upload-url or handles the upload itself.

Basically, if you can get storage uploads to work via an http endpoint, you can get any kind of upload mechanism working. (eg the FileSystem.uploadAsync() method you’re probably looking for here, like @brentvatne pointed out, or fetch, or axios. I’ll show a basic wiring at the end).

Server Side

Option 1: Signed URL Upload.

Basically, have a small firebase function that returns a signed url. Your app calls a cloud function like /get-signed-upload-url , which returns the url, which you then use. Check out: https://cloud.google.com/storage/docs/access-control/signed-urls for how you’d go about this.

This might work well for your use case. It can be configured just like any httpsCallable function, so it’s not much work to set up, compared to option 2.

However, this doesn’t work for the firebase storage / functions emulator! For this reason, I don’t use this method, because I like to intensively use the emulators, and they only offer a subset of all the functionalities.

Option 2: Upload the file entirely through a function

This is a little hairier, but gives you a lot more fidelity over your uploads, and will work on an emulator! I like this too because it allows doing upload process within the endpoint execution, instead of as a side effect.

For example, you can have a photo-upload endpoint generate thumbnails, and if the endpoint 201’s, then you’re good! Rather than the traditional Firebase approach of having a listener to cloud storage which would generate thumbnails as a side effect, which then has all kinds of bad race conditions (checking for processing completion via exponentiational backoff? Gross!)

Here are three resources I’d recommend to go about this approach:

Basically, if you can make a Firebase cloud endpoint that accepts a File within formdata, you can have busboy parse it, and then you can do anything you want with it… like upload it to Cloud Storage!

an outline of this:

import * as functions from "firebase-functions";
import * as busboy from "busboy";
import * as os from "os";
import * as path from "path";
import * as fs from "fs";

type FieldMap = {
  [fieldKey: string]: string;
};

type Upload = {
  filepath: string;
  mimeType: string;
};

type UploadMap = {
  [fileName: string]: Upload;
};

const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB

export const uploadPhoto = functions.https.onRequest(async (req, res) => {
  verifyRequest(req); // Verify parameters, auth, etc. Better yet, use a middleware system for this like express.

  // This object will accumulate all the fields, keyed by their name
  const fields: FieldMap = {};

  // This object will accumulate all the uploaded files, keyed by their name.
  const uploads: UploadMap = {};

  // This will accumulator errors during the busboy process, allowing us to end early.
  const errors: string[] = [];

  const tmpdir = os.tmpdir();

  const fileWrites: Promise<unknown>[] = [];

  function cleanup() {
    Object.entries(uploads).forEach(([filename, { filepath }]) => {
      console.log(`unlinking: ${filename} from ${path}`);
      fs.unlinkSync(filepath);
    });
  }

  const bb = busboy({
    headers: req.headers,
    limits: {
      files: 1,
      fields: 1,
      fileSize: MAX_FILE_SIZE,
    },
  });

  bb.on("file", (name, file, info) => {
    verifyFile(name, file, info); // Verify your mimeType / filename, etc.
    file.on("limit", () => {
      console.log("too big of file!");
    });

    const { filename, mimeType } = info;
    // Note: os.tmpdir() points to an in-memory file system on GCF
    // Thus, any files in it must fit in the instance's memory.
    console.log(`Processed file ${filename}`);
    const filepath = path.join(tmpdir, filename);
    uploads[filename] = {
      filepath,
      mimeType,
    };

    const writeStream = fs.createWriteStream(filepath);
    file.pipe(writeStream);

    // File was processed by Busboy; wait for it to be written.
    // Note: GCF may not persist saved files across invocations.
    // Persistent files must be kept in other locations
    // (such as Cloud Storage buckets).
    const promise = new Promise((resolve, reject) => {
      file.on("end", () => {
        writeStream.end();
      });
      writeStream.on("finish", resolve);
      writeStream.on("error", reject);
    });
    fileWrites.push(promise);
  });

  bb.on("close", async () => {
    await Promise.all(fileWrites);

    // Fail if errors:
    if (errors.length > 0) {
      functions.logger.error("Upload failed", errors);
      res.status(400).send(errors.join());
    } else {
      try {
        const upload = Object.values(uploads)[0];

        if (!upload) {
          functions.logger.debug("No upload found");
          res.status(400).send("No file uploaded");
          return;
        }

        const { uploadId } = await processUpload(upload, userId);

        cleanup();

        res.status(201).send({
          uploadId,
        });
      } catch (error) {
        cleanup();
        functions.logger.error("Error processing file", error);
        res.status(500).send("Error processing file");
      }
    }
  });

  bb.end(req.rawBody);
});

Then, that processUpload function can do anything you want with the file, like upload it to cloud storage:

async function processUpload({ filepath, mimeType }: Upload, userId: string) {
    const fileId = uuidv4();
    const bucket = admin.storage().bucket(); 
    await bucket.upload(filepath, {
        destination: `users/${userId}/${fileId}`,
        {
          contentType: mimeType,
        },
    });
    return { fileId };
}

Mobile Side

Then, on the mobile side, you can interact with it like this:

async function uploadFile(uri: string) {

function getFunctionsUrl(): string {
  if (USE_EMULATOR) {
    const origin =
      Constants?.manifest?.debuggerHost?.split(":").shift() || "localhost";
    const functionsPort = 5001;
    const functionsHost = `http://${origin}:${functionsPort}/{PROJECT_NAME}/us-central1`;
    return functionsHost;
  } else {
    return `https://{PROJECT_LOCATION}-{PROJECT_NAME}.cloudfunctions.net`;
  }
}


  // The url of your endpoint. Make this as smart as you want.
  const url = `${getFunctionsUrl()}/uploadPhoto`;
  await FileSystem.uploadAsync(uploadUrl, uri, {
    httpMethod: "POST",
    uploadType: FileSystem.FileSystemUploadType.MULTIPART,
    fieldName: "file", // Important! make sure this matches however you want bussboy to validate the "name" field on file.
    mimeType,
    headers: {
      "content-type": "multipart/form-data",
      Authorization: `${idToken}`,
    },
  });
});

TLDR

Wrap Cloud Storage in your own endpoint, treat it like a normal http upload, everything plays nice.

Leave a Comment