http-request-mock
Version:
Intercept & mock http requests issued by XMLHttpRequest, fetch, nodejs https/http module, axios, jquery, superagent, ky, node-fetch, request, got or any other request libraries by intercepting XMLHttpRequest, fetch and nodejs native requests in low level.
310 lines (284 loc) • 8.23 kB
JavaScript
/* eslint-env node */
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const zlib = require('zlib');
const { Transform } = require('stream');
const entryPoints = [
'src/index.js',
'http-request-mock.js',
'http-request-mock.pure.js',
'http-request-mock.esm.mjs',
'http-request-mock.pure.esm.mjs'
];
const defaultHeadersForProxyServer = {
'x-powered-by': 'http-request-mock',
'access-control-allow-origin': '*',
'access-control-allow-methods': '*',
'access-control-allow-headers': '*',
'access-control-expose-headers': '*',
'access-control-allow-credentials': 'true',
};
module.exports = {
entryPoints,
defaultHeadersForProxyServer,
log,
tryToParseJson,
setLocalStorage,
reloadRuntime,
getAppRoot,
resolve,
formatPath,
watchDir,
responseTransform,
getRequestBody,
parseQuery,
doProxy
};
/**
* Common log
* @param {...any} args
*/
function log(...args) {
console.log('\x1b[32m[http-request-mock]\x1b[0m', ...args);
}
/**
* Try to parse a JSON string
* @param {unknown} body
*/
function tryToParseJson(str, defaultVal = null) {
try {
return JSON.parse(String(str));
} catch (e) {
return defaultVal;
}
}
/**
* Set a global localStorage object for the compatibility of cache plugin.
*/
function setLocalStorage() {
let localStorageCache = {};
global.localStorage = {
get length() {
return Object.keys(localStorageCache).length;
},
setItem(key, val) {
localStorageCache[key] = val;
},
getItem(key) {
return localStorageCache[key];
},
clear() {
localStorageCache = {};
},
removeItem(key) {
delete localStorageCache[key];
},
// https://developer.mozilla.org/en-US/docs/Web/API/Storage/key
key(index) {
return Object.keys(localStorageCache)[index];
}
};
}
/**
* Reload .runtime.js and the specified mock files.
* @param {string} mockDirectory
* @param {string[]} files
*/
function reloadRuntime(mockDirectory, files = []) {
files.forEach((file) => {
const relativeFile = path.relative(mockDirectory, file);
try {
delete require.cache[require.resolve(path.resolve(file))];
log('reload mock file:', relativeFile);
} catch (e) {
log(`reload mock file ${relativeFile} error: `, e.message);
}
});
try {
const runtime = require.resolve(path.resolve(mockDirectory, '.runtime.js'));
const mocker = require(runtime);
mocker && mocker.reset();
delete require.cache[runtime];
require(runtime);
} catch (err) {
log('reload .runtime.js error: ', err);
}
}
/**
* Get root directory of current application
*/
function getAppRoot() {
if (!/\bnode_modules\b/.test(__dirname)) return process.cwd();
const root = __dirname.split('node_modules')[0];
const json = path.resolve(root, 'package.json');
if (!fs.existsSync(json)) return process.cwd();
return fs.readFileSync(json, 'utf8').includes('"http-request-mock"') ? root : process.cwd();
}
/**
* Resolve path but treat '\' as '/' on windows
* @param {any} args
*/
function resolve(...args) {
return formatPath(path.resolve(...args));
}
/**
* Treat '\' as '/' on windows
* @param {string} path
* @returns
*/
function formatPath(path) {
return process.platform === 'win32' ? (path+'').replace(/\\/g, '/') : path;
}
/**
* Watch mock directory & update .runtime.js.
* @param {object} webpackInstance
* @param {string} mockDirectory
* @param {function} reloadFunction
*/
function watchDir(webpackInstance, mockDirectory, reloadFunction) {
const pathsSet = new Set();
let timer = null;
webpackInstance.setRuntimeConfigFile(); // update .runtime.js before watching
chokidar
.watch(mockDirectory, { ignoreInitial: true })
.on('all', (event, filePath) => {
const filename = path.basename(filePath);
// Only watch file that matches /^[\w][-\w]*\.js$/
if (event === 'addDir' || event === 'error') return;
if (filename && !/^[\w][-\w]*\.js$/.test(filename)) return;
if (pathsSet.has(filePath)) return;
pathsSet.add(filePath);
clearTimeout(timer);
timer = setTimeout(() => {
webpackInstance.setRuntimeConfigFile();
reloadFunction([...pathsSet]);
console.log(' ');
pathsSet.clear();
}, 100);
});
}
/**
* Generate a stream transform for response.
* @param {object} response
* @param {function | undefined} handler
*/
function responseTransform(response, handler) {
const buf = [];
return new Transform({
construct(callback) {
callback();
},
transform(chunk, _, callback) { // _: encoding
buf.push(chunk);
callback();
},
async flush(callback) {
if (typeof handler === 'function') {
const body = await handler(response, Buffer.concat(buf).toString());
this.push(Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)));
} else {
this.push(Buffer.concat(buf));
}
callback();
}
});
}
/**
* Get body for the specified request.
* @param {object} request
*/
function getRequestBody(request) {
if (String(request.method) === 'GET') {
return undefined;
}
const buf = [];
return new Promise((resolve, reject) => {
request
.on('error', err => reject(err))
.on('data', chunk => buf.push(chunk))
.on('end', () => {
let body = Buffer.concat(buf).toString();
if (body) {
try {
body = JSON.parse(body);
} catch (err) {
if (request.headers['content-type'] === 'application/x-www-form-urlencoded') {
body = parseQuery(body);
}
}
}
resolve(body);
});
});
}
/**
* Parse search query.
* @param {object|string} search
*/
function parseQuery(search) {
return [...new URLSearchParams(search).entries()].reduce((res, [key, val]) => {
// for keys which ends with square brackets, such as list[] or list[1]
if (key.match(/\[(\d+)?\]$/)) {
const field = key.replace(/\[(\d+)?\]/, '');
res[field] = res[field] || [];
if (key.match(/\[\d+\]$/)) {
res[field][Number(/\[(\d+)\]/.exec(key)[1])] = val;
} else {
res[field].push(val);
}
return res;
}
if (key in res) {
res[key] = [].concat(res[key] , val);
} else {
res[key] = val;
}
return res;
}, {});
}
/**
* Proxy the received request to the specified url.
* The specified url is with protocol such as: http://jsonplaceholder.typicode.com/todos/1
* @param {object} proxyInstance node-http-proxy instance
* @param {object} req
* @param {object} res
* @param {string} url
* @param {function | undefined} handler
* @returns
*/
function doProxy({proxyInstance, req, res, url, handler, headers}) {
const { protocol, host, pathname, search } = new URL(url);
req.url = `${pathname}${search}`;
return new Promise((resolve, reject) => {
proxyInstance.once('proxyRes', (proxyRes, _, res) => {
const transform = responseTransform(proxyRes, handler);
proxyRes.once('end', () => resolve(true));
// http://nodejs.cn/api/zlib/compressing_http_requests_and_responses.html
// ignore pipe for 204(No Content)
const zipHeaders = ['gzip', 'compress', 'deflate'];
if (zipHeaders.includes(proxyRes.headers['content-encoding']) && proxyRes.statusCode !== 204) {
proxyRes.pipe(zlib.createUnzip()).pipe(transform).pipe(res);
} else if (proxyRes.headers['content-encoding'] === 'br' && proxyRes.statusCode !== 204) {
proxyRes.pipe(zlib.createBrotliDecompress()).pipe(transform).pipe(res);
} else {
proxyRes.pipe(transform).pipe(res);
}
});
const opts = {
changeOrigin: true,
target: `${protocol}//${host}`,
cookieDomainRewrite: '',
followRedirects: true,
selfHandleResponse: true,
};
// headers: object with extra headers to be added to target requests.
if (headers && typeof headers === 'object') {
opts.headers = headers;
}
proxyInstance.web(req, res, opts, (err) => {
log(`proxy error[${url}]: `, err.message);
reject(err);
});
});
}