Read the core source code of Axios

After reading this article, the following questions will be solved,

  • What is the principle of Axios Adapter?
  • How does Axios implement request and response interception?
  • How does Axios cancel requests work?
  • What is the principle of CSRF? How does Axios protect against client CSRF attacks?
  • How is request and response data conversion implemented?

The full text is about 2000 words and takes about 6 minutes to read. The Axios version in the text is 0.21.1

We use features as the entrance to answer the above questions and feel the art of minimalist packaging of Axios source code.

Features

  • Create XMLHttpRequest from browser
  • Create HTTP request from Node.js
  • Promise API supported
  • Intercept requests and responses
  • Cancel request
  • Automatically install and replace JSON data
  • Support client XSRF attacks

The first two features explain why Axios can be used for both browser and Node.js. In short, it is determined whether to use XMLHttpRequest or Node.js HTTP to create requests by judging whether it is a server or browser environment. This compatible logic is called an adapter, and the corresponding source code is in lib/defaults.js,

// defaults.js
function getDefaultAdapter() {
  var adapter;
  if (typeof XMLHttpRequest !== 'undefined') {
    // For browsers use XHR adapter
    adapter = require('./adapters/xhr');
  } else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    // For node use HTTP adapter
    adapter = require('./adapters/http');
  }
  return adapter;
}

The above is the judgment logic of the adapter. It determines which adapter to use by detecting some global variables of the current environment. The judgment logic for the Node environment can also be reused when we render the ssr server. Next, let's take a look at Axios's encapsulation of adapters.

Adapter xhr

Locate the source file lib/adapters/xhr.js, and take a look at the overall structure,

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    // ...
  })
}

A function is exported, which accepts a configuration parameter and returns a Promise. We extract the key parts,

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    var requestData = config.data;

    var request = new XMLHttpRequest();

    request.open(config.method.toUpperCase(), buildURL(fullPath, config.params, config.paramsSerializer), true);
    request.onreadystatechange = function handleLoad() {}
    request.onabort = function handleAbort() {}
    request.onerror = function handleError() {}
    request.ontimeout = function handleTimeout() {}

    request.send(requestData);
  });
};

Does it feel familiar? Yes, this is the use posture of XMLHttpRequest. First create an xhr, then open the request, listen to the xhr status, and then send the request. Let's expand to see how Axios handles onreadystatechange,

request.onreadystatechange = function handleLoad() {
  if (!request || request.readyState !== 4) {
    return;
  }

  // The request errored out and we didn't get a response, this will be
  // handled by onerror instead
  // With one exception: request that using file: protocol, most browsers
  // will return status as 0 even though it's a successful request
  if (request.status === 0 && !(request.responseURL && request.responseURL.indexOf('file:') === 0)) {
    return;
  }

  // Prepare the response
  var responseHeaders = 'getAllResponseHeaders' in request ? parseHeaders(request.getAllResponseHeaders()) : null;
  var responseData = !config.responseType || config.responseType === 'text' ? request.responseText : request.response;
  var response = {
    data: responseData,
    status: request.status,
    statusText: request.statusText,
    headers: responseHeaders,
    config: config,
    request: request
  };

  settle(resolve, reject, response);

  // Clean up request
  request = null;
};

First, filter the status. Only when the request is completed (readyState === 4) will it be processed downward. It should be noted that if the XMLHttpRequest request makes an error, we can handle it by listening to onerror in most cases, but there is one exception: when the request uses the File Protocol (file: / /), although the request is successful, most browsers will return a status code of 0.

Axios also handles this exception.

Once the request is complete, the response is processed. Here, the response is wrapped into a standard format object and passed to the setter method as the third parameter. The setter is defined in lib / core / sette.js,

function settle(resolve, reject, response) {
  var validateStatus = response.config.validateStatus;
  if (!response.status || !validateStatus || validateStatus(response.status)) {
    resolve(response);
  } else {
    reject(createError(
      'Request failed with status code ' + response.status,
      response.config,
      null,
      response.request,
      response
    ));
  }
};

Set simply encapsulates the Promise callback to ensure that the call returns in a certain format.

The above is the main logic of xhrAdapter. The rest is the simple processing of request header, some supported configuration items, timeout, error, cancellation request and other callbacks. The prevention of XSRF attack is realized through request header.

Let's briefly review what XSRF is (also known as CSRF, Cross Site Request Forgery).

CSRF

Background: after logging in, the user needs to store the login credentials to maintain the login status, instead of sending the account password every request.

How to keep login status?

At present, the more common way is that after the server receives the HTTP request, it adds the set Cookie option in the response header to store the credentials in the Cookie. After the browser receives the response, it will store the Cookie. According to the browser's homology policy, it will automatically carry the Cookie to cooperate with the server authentication next time it sends a request to the server, so as to maintain the user's login status.

Therefore, if we do not judge the legitimacy of the request source and send a forged request to the server through other websites after logging in, the Cookie carrying the login credentials will be sent to the server along with the forged request, resulting in a security vulnerability. This is what we call CSRF, cross site request forgery.

Therefore, the key to preventing forged requests is to check the source of the request. Although the referer field can identify the current site, it is not reliable enough. Now the industry's common solution is to attach an anti CSRF token to each request. The principle of this is that the attacker cannot get the Cookie, so we can encrypt the Cookie (such as sid) Then, cooperate with the server to do some simple verification to judge whether the current request is forged.

Axios simply supports special CSRF tokens,

// Add xsrf header
// This is only done if running in a standard browser environment.
// Specifically not if we're in a web worker, or react-native.
if (utils.isStandardBrowserEnv()) {
  // Add xsrf header
  var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?
    cookies.read(config.xsrfCookieName) :
    undefined;

  if (xsrfValue) {
    requestHeaders[config.xsrfHeaderName] = xsrfValue;
  }
}

Interceptor

Interceptor is a Feature of Axios. Let's briefly review the usage,

// Interceptors can intercept requests or responses
// The interceptor's callback will be called before the then or catch callback of the request or response
var instance = axios.create(options);

var requestInterceptor = axios.interceptors.request.use(
  (config) => {
    // do something before request is sent
    return config;
  },
  (err) => {
    // do somthing with request error
    return Promise.reject(err);
  }
);

// Remove set interceptors
axios.interceptors.request.eject(requestInterceptor)

So how is the interceptor implemented?

Go to line 14 of the source code lib/core/Axios.js,

function Axios(instanceConfig) {
  this.defaults = instanceConfig;
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}

You can see from the constructor of Axios that both request and response in interceptors are instances called interceptor manager. What is this interceptor manager?

Locate the source code lib/core/InterceptorManager.js,

function InterceptorManager() {
  this.handlers = [];
}

InterceptorManager.prototype.use = function use(fulfilled, rejected, options) {
  this.handlers.push({
    fulfilled: fulfilled,
    rejected: rejected,
    synchronous: options ? options.synchronous : false,
    runWhen: options ? options.runWhen : null
  });
  return this.handlers.length - 1;
};

InterceptorManager.prototype.eject = function eject(id) {
  if (this.handlers[id]) {
    this.handlers[id] = null;
  }
};

InterceptorManager.prototype.forEach = function forEach(fn) {
  utils.forEach(this.handlers, function forEachHandler(h) {
    if (h !== null) {
      fn(h);
    }
  });
};

Interceptor manager is a simple event manager that manages interceptors,

Store interceptors through handlers, and then provide instance methods for adding, removing and traversing interceptors. Each interceptor object stored contains callbacks as resolve and reject in Promise and two configuration items.

It is worth mentioning that the removal method is implemented by directly setting the interceptor object to null, rather than slicing the array. The corresponding null value processing is also added to the traversal method. In this way, on the one hand, the array index of each item ID remains unchanged, and on the other hand, the performance loss of re cutting and splicing arrays is avoided.

The interceptor's callback will be called before the then or catch callback of the request or response. How is this implemented?

Back to line 27 of the source code lib/core/Axios.js, the request method of the Axios instance object,

We extract the key logic as follows,

Axios.prototype.request = function request(config) {
  // Get merged config
  // Set config.method
  // ...
  var requestInterceptorChain = [];
  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

 var responseInterceptorChain = [];
  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
  });

  var promise;

  var chain = [dispatchRequest, undefined];

  Array.prototype.unshift.apply(chain, requestInterceptorChain);

  chain.concat(responseInterceptorChain);

  promise = Promise.resolve(config);

  while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
  }

  return promise;
};

You can see that when the request is executed, the actual dispatchRequest and interceptor are managed through a queue called chain. The logic of the whole request is as follows,

  1. First, initialize the interceptor queue of request and response, and put the resolve and reject callbacks into the queue head in turn
  2. Then initialize a Promise to execute the callback, and the chain is used to store and manage the actual requests and interceptors
  3. Put the request interceptor into the head of the chain queue and the response interceptor into the tail of the chain queue
  4. When the queue is not empty, the request interceptor, the actual request and the response interceptor will be dequeued through the chain call of Promise.then
  5. Finally, the Promise after chain call is returned

The actual request here is the encapsulation of the adapter, and the conversion of request and response data is completed here.

So how is data conversion implemented?

Transform data

Locate the source code lib/core/dispatchRequest.js,

function dispatchRequest(config) {
  throwIfCancellationRequested(config);

  // Transform request data
  config.data = transformData(
    config.data,
    config.headers,
    config.transformRequest
  );
  
  var adapter = config.adapter || defaults.adapter;

  return adapter(config).then(function onAdapterResolution(response) {
    throwIfCancellationRequested(config);

    // Transform response data
    response.data = transformData(
      response.data,
      response.headers,
      config.transformResponse
    );

    return response;
  }, function onAdapterRejection(reason) {
    if (!isCancel(reason)) {
      throwIfCancellationRequested(config);

      // Transform response data
      if (reason && reason.response) {
        reason.response.data = transformData(
          reason.response.data,
          reason.response.headers,
          config.transformResponse
        );
      }
    }

    return Promise.reject(reason);
  });
};

The throwIfCancellationRequested method here is used to cancel the request. We will discuss the cancellation request later. We can see that the sending request is realized by calling the adapter, and the request and response data will be converted before and after the call.

The conversion is realized through the transformData function, which will traverse the conversion function set by the call. The conversion function takes headers as the second parameter, so we can perform some different conversion operations according to the information in headers,

// Source code: core/transformData.js
function transformData(data, headers, fns) {
  utils.forEach(fns, function transform(fn) {
    data = fn(data, headers);
  });

  return data;
};

Axios also provides two default transformation functions for transforming request and response data. By default,

Axios handles the data passed in by the request. For example, if the request data is an object, it will be serialized into a JSON string, and if the response data is a JSON string, it will try to convert it into a JavaScript object. These are very practical functions,

The corresponding converter source code can be found in line 31 of lib/default.js,

var defaults = {
 // Line 31
  transformRequest: [function transformRequest(data, headers) {
    normalizeHeaderName(headers, 'Accept');
    normalizeHeaderName(headers, 'Content-Type');
    if (utils.isFormData(data) ||
      utils.isArrayBuffer(data) ||
      utils.isBuffer(data) ||
      utils.isStream(data) ||
      utils.isFile(data) ||
      utils.isBlob(data)
    ) {
      return data;
    }
    if (utils.isArrayBufferView(data)) {
      return data.buffer;
    }
    if (utils.isURLSearchParams(data)) {
      setContentTypeIfUnset(headers, 'application/x-www-form-urlencoded;charset=utf-8');
      return data.toString();
    }
    if (utils.isObject(data)) {
      setContentTypeIfUnset(headers, 'application/json;charset=utf-8');
      return JSON.stringify(data);
    }
    return data;
  }],
  
  transformResponse: [function transformResponse(data) {
    var result = data;
    if (utils.isString(result) && result.length) {
      try {
        result = JSON.parse(result);
      } catch (e) { /* Ignore */ }
    }
    return result;
  }],
}

We say that Axios supports cancellation requests. How to cancel?

CancelToken

In fact, both the xhr on the browser side and the request object of the http module in Node.js provide an abort method to cancel the request, so we only need to call abort at the right time to cancel the request.

So what is the right time? It's appropriate to give control to the user. Therefore, the appropriate time should be decided by the user, that is, we need to expose the method of canceling the request. Axios cancels the request through CancelToken. Let's take a look at its posture.

First, Axios provides two ways to create a cancel token,

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

// Method 1: use the static attribute source provided by the CancelToken instance
axios.post("/user/12345", { name: "monch" }, { cancelToken: source.token });
source.cancel();

// Method 2: use the CancelToken constructor to instantiate by yourself
let cancel;

axios.post(
  "/user/12345",
  { name: "monch" },
  {
    cancelToken: new CancelToken(function executor(c) {
      cancel = c;
    }),
  }
);

cancel();

What exactly is CancelToken? Go to line 11 of the source code lib/cancel/CancelToken.js,

function CancelToken(executor) {
  if (typeof executor !== "function") {
    throw new TypeError("executor must be a function.");
  }

  var resolvePromise;
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  executor(function cancel(message) {
    if (token.reason) {
      // Cancellation has already been requested
      return;
    }

    token.reason = new Cancel(message);
    resolvePromise(token.reason);
  });
}

CancelToken is a minimalist state machine controlled by promise. When instantiating, a promise will be attached to the instance. The resolve callback of the promise is exposed to the external method executor. In this way, after calling the executor method from the outside, we will get a promise whose state will change to full, How can we cancel the request with this promise?

Is it OK to just get the promise instance at the time of the request and cancel the request in the then callback?

Locate the source code lib/adapters/xhr.js of the adapter at line 158,

if (config.cancelToken) {
  // Handle cancellation
  config.cancelToken.promise.then(function onCanceled(cancel) {
    if (!request) {
      return;
    }

    request.abort();
    reject(cancel);
    // Clean up request
    request = null;
  });
}

And line 291 of the source code lib / adapters / http.js,

if (config.cancelToken) {
  // Handle cancellation
  config.cancelToken.promise.then(function onCanceled(cancel) {
    if (req.aborted) return;

    req.abort();
    reject(cancel);
  });
}

If so, the abort method of xhr or http.request is called in the then callback of promise of the CancelToken instance in the adapter. Just imagine, if we do not call the CancelToken method from the outside, does it mean that the resolve callback will not be executed, and the then callback of promise in the adapter will not be executed, so we will not call abort to cancel the request.

Summary

Axios is encapsulated by the adapter, so that it can be used in both the browser and node.js on the premise of maintaining the same set of interface specifications. Promise and closure are widely used in the source code to realize a series of state control. For the interceptor, the implementation of cancellation request reflects its minimalist encapsulation art, which is worthy of learning and reference.

Reference link

  • Axios Docs - axios-http.com[1]
  • Axios Github Source Code[2]
  • Axios -- the art of minimalist packaging [3]
  • Cross Site Request Forgery - Part III. Web Application Security[4]
  • tc39/proposal-cancelable-promises[5]

Write at the end

This article was first published in my blog [6]. I have little talent and learning. It is inevitable that there will be mistakes. I hope you can correct the mistakes in the article!

If you have questions or find errors, you can ask questions or make corrections in the corresponding issues

If you like it or have some inspiration, welcome star, which is also an encouragement to the author

(end)

Author: campcc https://github.com/campcc/blog/issues/23

Added by donald on Wed, 17 Nov 2021 05:41:29 +0200