Working with Buffers and Blobs in Managed Expo

You can find yourself in a weird position when working with React Native. It kinda feels like you’re on the server and it kinda feels like you’re in the browser. The reality is that you’re in neither.

Due to the ubiquity of JavaScript we’ve become used to isomorphism (or maybe Universal JavaScript?) in JS apps, a concept that will continue to build as Next.js 13 makes React Server Components accessible to more developers. Developers will be naturally expecting their favourite libraries to run where ever they want to use them. Universal JavaScript right now though tends to mean the ability to run on the server and in a browser. React Native is different.

There are some key low level data structures missing, a problem that’s been around a while. The most notable is probably the Buffer API. This makes dealing with binary data challenging and and parsing anything that isn’t text particularly difficult. We do though have access to the Blob object, but it’s API is limited. Actually reading out and working with that data is near impossible without a Buffer to put it in. It’s like a big black box of data you can’t actually read.

Luckily, there are a number of React Native packages that can provide the missing support:

These packages are no good though if you’re trying to stick to Expo’s Managed Workflow. I like to stay Managed as long as I can to simplify the development (Expo Go) and build process. I’m not a Mobile Engineer and so having to navigate Xcode or Android Studio really isn’t my idea of fun. Sticking to Managed let’s me work quickly and makes staying on the Expo upgrade path trivial. The trade off for this way of working though is that I can’t make changes to the iOS and Android codebases, only the TypeScript and Expo configuration files.

How did I get here? I was working with printers. I had an app that needs to get some status information from a printer on the mobile device’s network. Now, printers have a nicely documented protocol called IPP which makes it really easy to talk to printers without needing proprietary SDKs or reverse engineering packets. While IPP communicates over HTTP, the payload itself is a binary message. HTTP means we can use XMLHttpRequest to actually make the request, and we can really easily POST binary data using its .send() method.

Reading the response is a whole other issue.

XMLHttpRequest.responseType gives us two real options for representing binary data: blob or arraybuffer. arraybuffer won’t work in the React Native environment, which makes blob the only option. Next, how do we actually read the response? Blob.arrayBuffer doesn’t exist in React Native.

So how do we start dealing with binary data with Expo Managed? There are some really nice user-land implementations that we can use. The backbone of this is feross/buffer. This package gives us a Buffer API that is identical to Node.js, a data structure we can use to interact with our binary data. What’s more, the package has two isomorphic packages as dependencies which themselves have zero dependencies. I guess we all learnt something from left-pad.

So how do you actually read binary data from a HTTP API using React Native? You use XMLHttpRequest with blob .responseType:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const request = (url: string, data: Parameters<XMLHttpRequest['send']>[0]) =>
  new Promise((resolve) => {
    const req = new XMLHttpRequest();

    req.open('POST', url, true);
    req.responseType = 'blob';

    req.onload = () => {
      // At this point, req.response is a Blob.
      resolve(req.response);
    };

    req.send(data);
  });

Great! You’d normally interact with the Blob by calling Blob.arrayBuffer. If you try that in React Native you’ll get something along the lines of .arrayBuffer is not a function. To massage this Blob into a Buffer, we need to use the a similar approach to blob-to-buffer (FileReader is implemented in the React Native core for us) but instead of reading as an array buffer, we read as a data URI:

1
2
3
4
5
6
7
8
9
const toDataURI = (blob: Blob) =>
  new Promise((resolve) => {
    const reader = new FileReader();
    reader.readAsDataURL(blob);
    reader.onloadend = () => {
      const uri = reader.result?.toString();
      resolve(uri);
    };
  });

At this stage we’ve got the Blob into a base64 encoded data URI. Now instead of the Blob being a black box we can actually see the data. The next step for us is to turn it into a Buffer by triming off the data URI metadata and just looking at the base64 encoded data itself. Note the way we have to require Buffer.

1
2
const Buffer = require('buffer/').Buffer;
const toBuffer = (uri: string) => Buffer.from(uri.replace(/^.\*,/g, ''), 'base64');

Putting all of this together, we can convert a Blob to a Buffer, with the following:

1
2
3
4
5
6
7
8
9
10
11
12
const Buffer = require('buffer/').Buffer;
const toBuffer = (blob: Blob) =>
  new Promise((resolve) => {
    const reader = new FileReader();
    reader.readAsDataURL(blob);
    reader.onloadend = () => {
      const uri = reader.result?.toString() ?? '';
      const base64 = uri.replace(/^.\*,/g, '');

      resolve(Buffer.from(base64, 'base64'));
    };
  });