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:
- https://github.com/craftzdog/react-native-buffer
- https://github.com/mrousavy/react-native-blob-jsi-helper
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'));
};
});