You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
256 lines
6.9 KiB
256 lines
6.9 KiB
6 months ago
|
import type { ViteMockOptions, MockMethod, Recordable, RespThisType } from './types';
|
||
|
|
||
|
import path from 'node:path';
|
||
|
import fs from 'node:fs';
|
||
|
import chokidar from 'chokidar';
|
||
|
import colors from 'picocolors';
|
||
|
import url from 'url';
|
||
|
import fg from 'fast-glob';
|
||
|
import Mock from 'mockjs';
|
||
|
import { pathToRegexp, match } from 'path-to-regexp';
|
||
|
import { isArray, isFunction, sleep, isRegExp, isAbsPath } from './utils';
|
||
|
import { IncomingMessage, NextHandleFunction } from 'connect';
|
||
|
import { bundleRequire } from 'bundle-require';
|
||
|
import type { ResolvedConfig } from 'vite';
|
||
|
|
||
|
export let mockData: MockMethod[] = [];
|
||
|
|
||
|
export async function createMockServer(
|
||
|
opt: ViteMockOptions = { mockPath: 'mock', configPath: 'vite.mock.config' },
|
||
|
config: ResolvedConfig,
|
||
|
) {
|
||
|
opt = {
|
||
|
mockPath: 'mock',
|
||
|
watchFiles: true,
|
||
|
configPath: 'vite.mock.config.ts',
|
||
|
logger: true,
|
||
|
...opt,
|
||
|
};
|
||
|
|
||
|
if (mockData.length > 0) return;
|
||
|
mockData = await getMockConfig(opt, config);
|
||
|
await createWatch(opt, config);
|
||
|
}
|
||
|
|
||
|
// request match
|
||
|
export async function requestMiddleware(opt: ViteMockOptions) {
|
||
|
const { logger = true } = opt;
|
||
|
const middleware: NextHandleFunction = async (req, res, next) => {
|
||
|
let queryParams: {
|
||
|
query?: {
|
||
|
[key: string]: any;
|
||
|
};
|
||
|
pathname?: string | null;
|
||
|
} = {};
|
||
|
|
||
|
if (req.url) {
|
||
|
queryParams = url.parse(req.url, true);
|
||
|
}
|
||
|
|
||
|
const reqUrl = queryParams.pathname;
|
||
|
|
||
|
const matchRequest = mockData.find((item) => {
|
||
|
if (!reqUrl || !item || !item.url) {
|
||
|
return false;
|
||
|
}
|
||
|
if (item.method && item.method.toUpperCase() !== req.method) {
|
||
|
return false;
|
||
|
}
|
||
|
return pathToRegexp(item.url).test(reqUrl);
|
||
|
});
|
||
|
|
||
|
if (matchRequest) {
|
||
|
const isGet = req.method && req.method.toUpperCase() === 'GET';
|
||
|
const { response, rawResponse, timeout, statusCode, url } = matchRequest;
|
||
|
|
||
|
if (timeout) {
|
||
|
await sleep(timeout);
|
||
|
}
|
||
|
|
||
|
const urlMatch = match(url, { decode: decodeURIComponent });
|
||
|
|
||
|
let query = queryParams.query as any;
|
||
|
if (reqUrl) {
|
||
|
if ((isGet && JSON.stringify(query) === '{}') || !isGet) {
|
||
|
const params = (urlMatch(reqUrl) as any).params;
|
||
|
if (JSON.stringify(params) !== '{}') {
|
||
|
query = (urlMatch(reqUrl) as any).params || {};
|
||
|
} else {
|
||
|
query = queryParams.query || {};
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const self: RespThisType = { req, res, parseJson: parseJson.bind(null, req) };
|
||
|
if (isFunction(rawResponse)) {
|
||
|
await rawResponse.bind(self)(req, res);
|
||
|
} else {
|
||
|
const body = await parseJson(req);
|
||
|
res.setHeader('Content-Type', 'application/json');
|
||
|
res.statusCode = statusCode || 200;
|
||
|
const mockResponse = isFunction(response)
|
||
|
? response.bind(self)({ url: req.url as any, body, query, headers: req.headers })
|
||
|
: response;
|
||
|
res.end(JSON.stringify(Mock.mock(mockResponse)));
|
||
|
}
|
||
|
|
||
|
logger && loggerOutput('request invoke', req.url!);
|
||
|
return;
|
||
|
}
|
||
|
next();
|
||
|
};
|
||
|
return middleware;
|
||
|
}
|
||
|
|
||
|
// create watch mock
|
||
|
function createWatch(opt: ViteMockOptions, config: ResolvedConfig) {
|
||
|
const { configPath, logger, watchFiles } = opt;
|
||
|
|
||
|
if (!watchFiles) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const { absConfigPath, absMockPath } = getPath(opt);
|
||
|
|
||
|
if (process.env.VITE_DISABLED_WATCH_MOCK === 'true') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const watchDir = [];
|
||
|
const exitsConfigPath = fs.existsSync(absConfigPath);
|
||
|
|
||
|
exitsConfigPath && configPath ? watchDir.push(absConfigPath) : watchDir.push(absMockPath);
|
||
|
|
||
|
const watcher = chokidar.watch(watchDir, {
|
||
|
ignored: opt.ignore || /.mjs$/,
|
||
|
ignoreInitial: true,
|
||
|
});
|
||
|
|
||
|
watcher.on('all', async (event, file) => {
|
||
|
logger && loggerOutput(`mock file ${event}`, file);
|
||
|
mockData = await getMockConfig(opt, config);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// clear cache
|
||
|
function cleanRequireCache(opt: ViteMockOptions) {
|
||
|
if (!require.cache) {
|
||
|
return;
|
||
|
}
|
||
|
const { absConfigPath, absMockPath } = getPath(opt);
|
||
|
Object.keys(require.cache).forEach((file) => {
|
||
|
if (file === absConfigPath || file.indexOf(absMockPath) > -1) {
|
||
|
delete require.cache[file];
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
function parseJson(req: IncomingMessage): Promise<Recordable> {
|
||
|
return new Promise((resolve) => {
|
||
|
let body = '';
|
||
|
let jsonStr = '';
|
||
|
req.on('data', function (chunk) {
|
||
|
body += chunk;
|
||
|
});
|
||
|
req.on('end', function () {
|
||
|
try {
|
||
|
jsonStr = JSON.parse(body);
|
||
|
} catch (err) {
|
||
|
jsonStr = '';
|
||
|
}
|
||
|
resolve(jsonStr as any);
|
||
|
return;
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// load mock .ts files and watch
|
||
|
async function getMockConfig(opt: ViteMockOptions, config: ResolvedConfig) {
|
||
|
cleanRequireCache(opt);
|
||
|
const { absConfigPath, absMockPath } = getPath(opt);
|
||
|
const { ignore, configPath, logger } = opt;
|
||
|
|
||
|
let ret: MockMethod[] = [];
|
||
|
|
||
|
if (configPath && fs.existsSync(absConfigPath)) {
|
||
|
logger && loggerOutput(`load mock data from`, absConfigPath);
|
||
|
ret = await resolveModule(absConfigPath, config);
|
||
|
return ret;
|
||
|
}
|
||
|
|
||
|
const mockFiles = fg
|
||
|
.sync(`**/*.{ts,mjs,js}`, {
|
||
|
cwd: absMockPath,
|
||
|
})
|
||
|
.filter((item) => {
|
||
|
if (!ignore) {
|
||
|
return true;
|
||
|
}
|
||
|
if (isFunction(ignore)) {
|
||
|
return ignore(item);
|
||
|
}
|
||
|
if (isRegExp(ignore)) {
|
||
|
return !ignore.test(path.basename(item));
|
||
|
}
|
||
|
return true;
|
||
|
});
|
||
|
|
||
|
try {
|
||
|
ret = [];
|
||
|
const resolveModulePromiseList = [];
|
||
|
|
||
|
for (let index = 0; index < mockFiles.length; index++) {
|
||
|
const mockFile = mockFiles[index];
|
||
|
resolveModulePromiseList.push(resolveModule(path.join(absMockPath, mockFile), config));
|
||
|
}
|
||
|
|
||
|
const loadAllResult = await Promise.all(resolveModulePromiseList);
|
||
|
for (const resultModule of loadAllResult) {
|
||
|
let mod = resultModule;
|
||
|
if (!isArray(mod)) {
|
||
|
mod = [mod];
|
||
|
}
|
||
|
ret = [...ret, ...mod];
|
||
|
}
|
||
|
} catch (error: any) {
|
||
|
loggerOutput(`mock reload error`, error);
|
||
|
ret = [];
|
||
|
}
|
||
|
return ret;
|
||
|
}
|
||
|
|
||
|
// Inspired by vite
|
||
|
// support mock .ts files
|
||
|
async function resolveModule(p: string, config: ResolvedConfig): Promise<any> {
|
||
|
const mockData = await bundleRequire({
|
||
|
filepath: p,
|
||
|
});
|
||
|
|
||
|
let mod = mockData.mod.default || mockData.mod;
|
||
|
if (isFunction(mod)) {
|
||
|
mod = await mod({ env: config.env, mode: config.mode, command: config.command });
|
||
|
}
|
||
|
return mod;
|
||
|
}
|
||
|
|
||
|
// get custom config file path and mock dir path
|
||
|
function getPath(opt: ViteMockOptions) {
|
||
|
const { mockPath, configPath } = opt;
|
||
|
const cwd = process.cwd();
|
||
|
const absMockPath = isAbsPath(mockPath) ? mockPath! : path.join(cwd, mockPath || '');
|
||
|
const absConfigPath = path.join(cwd, configPath || '');
|
||
|
return {
|
||
|
absMockPath,
|
||
|
absConfigPath,
|
||
|
};
|
||
|
}
|
||
|
|
||
|
function loggerOutput(title: string, msg: string, type: 'info' | 'error' = 'info') {
|
||
|
const tag = type === 'info' ? colors.cyan(`[vite:mock]`) : colors.red(`[vite:mock-server]`);
|
||
|
return console.log(
|
||
|
`${colors.dim(new Date().toLocaleTimeString())} ${tag} ${colors.green(title)} ${colors.dim(
|
||
|
msg,
|
||
|
)}`,
|
||
|
);
|
||
|
}
|