It seems like every time you turn around, there's a new JS library that does XHR. Of the popular XHR libraries seem to do essentially the same thing and promise some trendy new feature that is already available in XmlHttpRequest or the fetch API.
The native Fetch API provides great functionality and performance improvements and a great interface, however since not all browsers and the Node JS ecosystem have fully adopted it, polyfills like isomorphic-fetch and node-fetch are required in order to be able to use everywhere.
When I found Superagent one day, I immediately had a gag-reflex due to seeing this really bizarre jQuery-like chaining approach. I definitely don't want to submit developers to implementation like this.
// Barf superagent .post('/api/pet') .send({ name: 'Manny', species: 'cat' }) // sends a JSON post body .set('X-API-Key', 'foobar') .set('accept', 'json') .end((err, res) => { // Calling the end function will send the request });
Axios is the newest library on the scene. It offers a lot of things like shared instances and supposedly better overall performance, but it feels way overpowered for such a simple thing and has too much configurability.
There are also a ton of libraries that started off well but upgrades to ECMAScript and NodeJS eventually caught with them. Libraries like request, oboe JS and Q JS had some good performance and features like streaming, however native promises ended up being much faster and all eventually became abandoned.
I don't think I even need to talk about jQuery here.
// But here's a snippet anyways $.ajax({ url: '/users', type: "GET", dataType: "json", success: function (data) { console.log(data); } fail: function () { console.log("Encountered an error") } });
There was no way I was ever going to use this as the best-practice XHR library but it feels like it still has a strong following for some reason.
I know I want a simple and intuitive interface, so I reviewed what each library offered to see what it is I want in the end.
Axios abstracts and invents custom properties so I want to make sure I don't fall into that trap. Things like data
, params
and the url
property are subjective and aren't clear what they mean without reading some documentation.
The fetch APIs have a unique argument signature, placing the URL in the first slot, but the way the headers and other config properties are supported to be passed is also confusing. Certain headers go through the headers
property, while others are able to specified by abstracted properties in the root of the config object.
// Hope you have your documentation handy! let promise = fetch(url, { method: "GET", // POST, PUT, DELETE, etc. headers: { // the content type header value is usually auto-set // depending on the request body "Content-Type": "text/plain;charset=UTF-8" }, body: undefined // string, FormData, Blob, BufferSource, or URLSearchParams referrer: "about:client", // or "" to send no Referer header, // or an url from the current origin referrerPolicy: "no-referrer-when-downgrade", // no-referrer, origin, same-origin... mode: "cors", // same-origin, no-cors credentials: "same-origin", // omit, include });
I honestly hate writing documentation since no one ever seems to read it, but I still do it! Mostly because I want to remember what I was thinking months or years ago when I wrote a piece of code. But with that in mind, I don't want to create a library that requires a ton of doc in order to understand how to use and implement it.
Keeping this thing as simple as possible is going to be key.
I want my library to be as dumb and unforgiving as possible, but have all of the creature comforts of modern libraries.
The last thing that really pains me is the amount of boilerplate each one requires for a single request. First, you need some exception handling code. You'll also want to make sure you parse and build the response correctly; if you specify an application/json
Content-Type header, you better remember to parse back to JSON. Oh, yea and don't forget to check the status code to make sure you've actually got data there (204 vs 200 hello!).
Oh yea, and this library isn't going to depend on Node JS 16 or some experimental ECMAScript feature. It's sad how many new developers don't know how to be backwards compatible or at least design for widely adopted standards.
I get it. Designing an HTTP library is a big undertaking and making it backwards compatible is a lot of work. but shouldn't that be expected? Sure, if your hobby application is only 1,000 loc then making the upgrade isn't a big deal.
First, my library is going to have explicit functions to support the different HTTP methods (which is how I think a library should work with to begin with). In my opinion, the http method is something that should really be encapsulated in the library and nothing should simply default it to 'GET' if you forgot to include it in a property.
I'll need to design one XHR library for the browser, and another for NodeJS applications.
I'll be relying heavily on the XmlHttpRequest docs as well as what the NodeJS devs have written for http(s).request.
Both libraries will support DELETE, GET, POST, PUT. Since I'm writing this for a closed system, I don't see a need to support things like OPTIONS or preflight etc.
Also, since all of the applications I support only speak JSON, both libraries will only accept JSON and only handle JSON content-types. This is where I think all libraries like the one I am building go off the rails since they just try and wrap and abstract the entire XmlHttpRequest / https(s).request implementations.
So keeping in mind what I've gone over so far I eventually settled on an argument signature like this:
import { CustomXmlHttpRequest } from 'my-npm-library' const { xhrDelete, xhrGet, xhrPost, xhrPut } = CustomXmlHttpRequest const [error, response] = await xhrXYZ({ body: { ... }, headers: { ... }, query: { ... }, url: '/api/pet', })
xhrXYZ
is just a stand-in for the different xhrGet
etc methods, and in actuality xhrDelete
, xhrGet
, xhrPost
, xhrPut
are just simple abstractions of the main http request method for the benefit of the implementer.
For example, here's the implementation of how I wrote xhrGet
:
function xhrGet ({ headers, query, url, withCredentials }) { return xhrAction({ headers, method: 'GET', query, url, withCredentials }, window.XMLHttpRequest) }
The destructured error
and response
variables are something special that I really wanted to do since my experience showed that any http request is always either wrapped in a try/catch or belonging to some promise chain. An implementation like this really helps me get rid of a ton of boilerplate and keep the code easy to read.
It's really simple:
// Wrap promise resolution / rejection and reduce try/catch boilerplate in implementations function asyncAwaitTryCatch (promise) { return promise .then((data = {}) => { return Promise.resolve([undefined, data]) }) .catch((error) => { return Promise.resolve([error, undefined]) }) }
and inside the actual library implementation is the function above just wrapping the promise surrounding the lower level http call:
function xhrAction ({ body, headers, method, query, url }) { return asyncAwaitTryCatch(new Promise(function (resolve, reject) { ... } }
and then keeping in mind the abstraction of xhrGet
etc is where the final pattern emerges:
const [error, response] = await xhrPost({ ... })
If there's multiple calls all writing to these variable declarations, it's just a simple matter of some destructured initializers.
I am really happy with how clean everything looks and the amount of code I cut out by doing things this way.
Status codes are one thing I noticed some of the libraries support, however I didn't have a requirement to make these available. I basically encapsulated the meanings behind them and returned the appropriate error or success response and let the application take it from there.
The main thing, as you can see below, is the need for each application to interpret a status code is gone (especially when it comes to needing to check if there even is a response).
const req = new XHR_Api() ... req.onload = function () { let response = req.response || req.responseText if (req.status === 204) { resolve({}) } else if (req.status >= 200 && req.status < 300) { try { response = JSON.parse(response) } catch (e) { reject(new Error('Unable to parse JSON')) } resolve(response) } else { reject(response) } }
Another benefit of this is if someday I decide I want to swap out XmlHttpRequest for fetch, I can leave all of the application callsites alone and just change the guts.
Since this library is already unit tested to the max, I don't have to worry about breaking existing functionality.
Later on, I also found out looking through the various applications for the upgrade that some pass through credentials. It feels dirty but I added in a withCredentials
property that just takes a boolean.