vite-plugin-mock-dev-server
Version:
Vite Plugin for API mock dev server.
718 lines (704 loc) • 23.4 kB
JavaScript
import { isArray, isBoolean, promiseParallel, toArray, uniq } from "./dist-CAA1v47s.js";
import { createDefineMock, createSSEStream, defineMock, defineMockData } from "./helper-DHb-Bj_j.js";
import { baseMiddleware, createLogger, debug, doesProxyContextMatchUrl, ensureProxies, logLevels, lookupFile, mockWebSocket, normalizePath, recoverRequest, sortByValidator, transformMockData, transformRawData, urlParse } from "./server-C-u7jwot.js";
import pc from "picocolors";
import fs, { promises } from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { createFilter } from "@rollup/pluginutils";
import fg from "fast-glob";
import isCore from "is-core-module";
import { pathToFileURL } from "node:url";
import { build } from "esbuild";
import JSON5 from "json5";
import { pathToRegexp } from "path-to-regexp";
import cors from "cors";
import EventEmitter from "node:events";
import chokidar from "chokidar";
//#region src/core/compiler.ts
const externalizeDeps = {
name: "externalize-deps",
setup(build$1) {
build$1.onResolve({ filter: /.*/ }, ({ path: id }) => {
if (id[0] !== "." && !path.isAbsolute(id)) return { external: true };
});
}
};
const json5Loader = {
name: "json5-loader",
setup(build$1) {
build$1.onLoad({ filter: /\.json5$/ }, async ({ path: path$1 }) => {
const content = await promises.readFile(path$1, "utf-8");
return {
contents: `export default ${JSON.stringify(JSON5.parse(content))}`,
loader: "js"
};
});
}
};
const jsonLoader = {
name: "json-loader",
setup(build$1) {
build$1.onLoad({ filter: /\.json$/ }, async ({ path: path$1 }) => {
const content = await promises.readFile(path$1, "utf-8");
return {
contents: `export default ${content}`,
loader: "js"
};
});
}
};
const renamePlugin = {
name: "rename-plugin",
setup(build$1) {
build$1.onResolve({ filter: /.*/ }, ({ path: id }) => {
if (id === "vite-plugin-mock-dev-server") return {
path: "vite-plugin-mock-dev-server/helper",
external: true
};
return null;
});
}
};
function aliasPlugin(alias) {
return {
name: "alias-plugin",
setup(build$1) {
build$1.onResolve({ filter: /.*/ }, async ({ path: id }) => {
const matchedEntry = alias.find(({ find: find$1 }) => aliasMatches(find$1, id));
if (!matchedEntry) return null;
const { find, replacement } = matchedEntry;
const result = await build$1.resolve(id.replace(find, replacement), {
kind: "import-statement",
resolveDir: replacement,
namespace: "file"
});
return {
path: result.path,
external: false
};
});
}
};
}
function aliasMatches(pattern, importee) {
if (pattern instanceof RegExp) return pattern.test(importee);
if (importee.length < pattern.length) return false;
if (importee === pattern) return true;
return importee.startsWith(`${pattern}/`);
}
async function transformWithEsbuild(entryPoint, options) {
const { isESM = true, define, alias, cwd = process.cwd() } = options;
const filepath = path.resolve(cwd, entryPoint);
const filename = path.basename(entryPoint);
const dirname = path.dirname(filepath);
try {
const result = await build({
entryPoints: [entryPoint],
outfile: "out.js",
write: false,
target: ["node18"],
platform: "node",
bundle: true,
metafile: true,
format: isESM ? "esm" : "cjs",
define: {
...define,
__dirname: JSON.stringify(dirname),
__filename: JSON.stringify(filename),
...isESM ? {} : { "import.meta.url": JSON.stringify(pathToFileURL(filepath)) }
},
plugins: [
aliasPlugin(alias),
renamePlugin,
externalizeDeps,
jsonLoader,
json5Loader
],
absWorkingDir: cwd
});
return {
code: result.outputFiles[0].text,
deps: result.metafile?.inputs || {}
};
} catch (e) {
console.error(e);
}
return {
code: "",
deps: {}
};
}
async function loadFromCode({ filepath, code, isESM, cwd }) {
filepath = path.resolve(cwd, filepath);
const ext = isESM ? ".mjs" : ".cjs";
const filepathTmp = `${filepath}.timestamp-${Date.now()}${ext}`;
const file = pathToFileURL(filepathTmp).toString();
await promises.writeFile(filepathTmp, code, "utf8");
try {
const mod = await import(file);
return mod.default || mod;
} finally {
try {
fs.unlinkSync(filepathTmp);
} catch {}
}
}
//#endregion
//#region src/core/build.ts
async function generateMockServer(ctx, options) {
const include = toArray(options.include);
const exclude = toArray(options.exclude);
const cwd = options.cwd || process.cwd();
let pkg = {};
try {
const pkgStr = lookupFile(options.context, ["package.json"]);
if (pkgStr) pkg = JSON.parse(pkgStr);
} catch {}
const outputDir = options.build.dist;
const content = await generateMockEntryCode(cwd, include, exclude);
const mockEntry = path.join(cwd, `mock-data-${Date.now()}.js`);
await fsp.writeFile(mockEntry, content, "utf-8");
const { code, deps } = await transformWithEsbuild(mockEntry, options);
const mockDeps = getMockDependencies(deps, options.alias);
await fsp.unlink(mockEntry);
const outputList = [
{
filename: path.join(outputDir, "mock-data.js"),
source: code
},
{
filename: path.join(outputDir, "index.js"),
source: generatorServerEntryCode(options)
},
{
filename: path.join(outputDir, "package.json"),
source: generatePackageJson(pkg, mockDeps)
}
];
try {
if (path.isAbsolute(outputDir)) {
for (const { filename } of outputList) if (fs.existsSync(filename)) await fsp.rm(filename);
options.logger.info(`${pc.green("✓")} generate mock server in ${pc.cyan(outputDir)}`);
for (const { filename, source } of outputList) {
fs.mkdirSync(path.dirname(filename), { recursive: true });
await fsp.writeFile(filename, source, "utf-8");
const sourceSize = (source.length / 1024).toFixed(2);
const name = path.relative(outputDir, filename);
const space = name.length < 30 ? " ".repeat(30 - name.length) : "";
options.logger.info(` ${pc.green(name)}${space}${pc.bold(pc.dim(`${sourceSize} kB`))}`);
}
} else for (const { filename, source } of outputList) ctx.emitFile({
type: "asset",
fileName: filename,
source
});
} catch (e) {
console.error(e);
}
}
function getMockDependencies(deps, alias) {
const list = /* @__PURE__ */ new Set();
const excludeDeps = [
"vite-plugin-mock-dev-server",
"connect",
"cors"
];
const isAlias = (p) => alias.find(({ find }) => aliasMatches(find, p));
Object.keys(deps).forEach((mPath) => {
const imports = deps[mPath].imports.filter((_) => _.external && !_.path.startsWith("<define:") && !isAlias(_.path)).map((_) => _.path);
imports.forEach((dep) => {
const name = normalizePackageName(dep);
if (!excludeDeps.includes(name) && !isCore(name)) list.add(name);
});
});
return Array.from(list);
}
function normalizePackageName(dep) {
const [scope, name] = dep.split("/");
if (scope[0] === "@") return `${scope}/${name}`;
return scope;
}
function generatePackageJson(pkg, mockDeps) {
const { dependencies = {}, devDependencies = {} } = pkg;
const dependents = {
...dependencies,
...devDependencies
};
const mockPkg = {
name: "mock-server",
type: "module",
scripts: { start: "node index.js" },
dependencies: {
connect: "^3.7.0",
["vite-plugin-mock-dev-server"]: `^1.9.2`,
cors: "^2.8.5"
},
pnpm: { peerDependencyRules: { ignoreMissing: ["vite"] } }
};
mockDeps.forEach((dep) => {
mockPkg.dependencies[dep] = dependents[dep] || "latest";
});
return JSON.stringify(mockPkg, null, 2);
}
function generatorServerEntryCode({ proxies, wsProxies, cookiesOptions, bodyParserOptions, priority, build: build$1 }) {
const { serverPort, log } = build$1;
return `import { createServer } from 'node:http';
import connect from 'connect';
import corsMiddleware from 'cors';
import { baseMiddleware, createLogger, mockWebSocket } from 'vite-plugin-mock-dev-server/server';
import mockData from './mock-data.js';
const app = connect();
const server = createServer(app);
const logger = createLogger('mock-server', '${log}');
const proxies = ${JSON.stringify(proxies)};
const wsProxies = ${JSON.stringify(wsProxies)};
const cookiesOptions = ${JSON.stringify(cookiesOptions)};
const bodyParserOptions = ${JSON.stringify(bodyParserOptions)};
const priority = ${JSON.stringify(priority)};
const compiler = { mockData }
mockWebSocket(compiler, server, { wsProxies, cookiesOptions, logger });
app.use(corsMiddleware());
app.use(baseMiddleware(compiler, {
formidableOptions: { multiples: true },
proxies,
priority,
cookiesOptions,
bodyParserOptions,
logger,
}));
server.listen(${serverPort});
console.log('listen: http://localhost:${serverPort}');
`;
}
async function generateMockEntryCode(cwd, include, exclude) {
const includePaths = await fg(include, { cwd });
const includeFilter = createFilter(include, exclude, { resolve: false });
const mockFiles = includePaths.filter(includeFilter);
let importers = "";
const exporters = [];
mockFiles.forEach((filepath, index) => {
const file = normalizePath(path.join(cwd, filepath));
importers += `import * as m${index} from '${file}';\n`;
exporters.push(`[m${index}, '${filepath}']`);
});
return `import { transformMockData, transformRawData } from 'vite-plugin-mock-dev-server/server';
${importers}
const exporters = [\n ${exporters.join(",\n ")}\n];
const mockList = exporters.map(([mod, filepath]) => {
const raw = mod.default || mod;
return transformRawData(raw, filepath);
});
export default transformMockData(mockList);`;
}
//#endregion
//#region src/core/mockCompiler.ts
function createMockCompiler(options) {
return new MockCompiler(options);
}
/**
* mock配置加载器
*/
var MockCompiler = class extends EventEmitter {
moduleCache = /* @__PURE__ */ new Map();
moduleDeps = /* @__PURE__ */ new Map();
cwd;
mockWatcher;
depsWatcher;
moduleType = "cjs";
_mockData = {};
constructor(options) {
super();
this.options = options;
this.cwd = options.cwd || process.cwd();
try {
const pkg = lookupFile(this.cwd, ["package.json"]);
this.moduleType = !!pkg && JSON.parse(pkg).type === "module" ? "esm" : "cjs";
} catch {}
}
get mockData() {
return this._mockData;
}
run(watch) {
const { include, exclude } = this.options;
/**
* 使用 rollup 提供的 include/exclude 规则,
* 过滤包含文件
*/
const includeFilter = createFilter(include, exclude, { resolve: false });
fg(include, { cwd: this.cwd }).then((files) => files.filter(includeFilter).map((file) => () => this.loadMock(file))).then((loadList) => promiseParallel(loadList, 10)).then(() => this.updateMockList());
if (!watch) return;
this.watchMockEntry();
this.watchDeps();
let timer = null;
this.on("mock:update", async (filepath) => {
if (!includeFilter(filepath)) return;
await this.loadMock(filepath);
if (timer) clearImmediate(timer);
timer = setImmediate(() => {
this.updateMockList();
this.emit("mock:update-end", filepath);
timer = null;
});
});
this.on("mock:unlink", async (filepath) => {
if (!includeFilter(filepath)) return;
this.moduleCache.delete(filepath);
this.updateMockList();
this.emit("mock:update-end", filepath);
});
}
watchMockEntry() {
const { include } = this.options;
const [firstGlob, ...otherGlob] = toArray(include);
const watcher = this.mockWatcher = chokidar.watch(firstGlob, {
ignoreInitial: true,
cwd: this.cwd
});
if (otherGlob.length > 0) otherGlob.forEach((glob) => watcher.add(glob));
watcher.on("add", async (filepath) => {
filepath = normalizePath(filepath);
this.emit("mock:update", filepath);
debug("watcher:add", filepath);
});
watcher.on("change", async (filepath) => {
filepath = normalizePath(filepath);
this.emit("mock:update", filepath);
debug("watcher:change", filepath);
});
watcher.on("unlink", async (filepath) => {
filepath = normalizePath(filepath);
this.emit("mock:unlink", filepath);
debug("watcher:unlink", filepath);
});
}
/**
* 监听 mock文件依赖的本地文件变动,
* mock依赖文件更新,mock文件也一并更新
*/
watchDeps() {
const oldDeps = [];
this.depsWatcher = chokidar.watch([], {
ignoreInitial: true,
cwd: this.cwd
});
this.depsWatcher.on("change", (filepath) => {
filepath = normalizePath(filepath);
const mockFiles = this.moduleDeps.get(filepath);
mockFiles?.forEach((file) => {
this.emit("mock:update", file);
});
});
this.depsWatcher.on("unlink", (filepath) => {
filepath = normalizePath(filepath);
this.moduleDeps.delete(filepath);
});
this.on("update:deps", () => {
const deps = [];
for (const [dep] of this.moduleDeps.entries()) deps.push(dep);
const exactDeps = deps.filter((dep) => !oldDeps.includes(dep));
if (exactDeps.length > 0) this.depsWatcher.add(exactDeps);
});
}
close() {
this.mockWatcher?.close();
this.depsWatcher?.close();
}
updateMockList() {
this._mockData = transformMockData(this.moduleCache);
}
updateModuleDeps(filepath, deps) {
Object.keys(deps).forEach((mPath) => {
const imports = deps[mPath].imports.map((_) => _.path);
imports.forEach((dep) => {
if (!this.moduleDeps.has(dep)) this.moduleDeps.set(dep, /* @__PURE__ */ new Set());
const cur = this.moduleDeps.get(dep);
cur.add(filepath);
});
});
this.emit("update:deps");
}
async loadMock(filepath) {
if (!filepath) return;
let isESM = false;
if (/\.m[jt]s$/.test(filepath)) isESM = true;
else if (/\.c[jt]s$/.test(filepath)) isESM = false;
else isESM = this.moduleType === "esm";
const { define, alias } = this.options;
const { code, deps } = await transformWithEsbuild(filepath, {
isESM,
define,
alias,
cwd: this.cwd
});
try {
const raw = await loadFromCode({
filepath,
code,
isESM,
cwd: this.cwd
}) || {};
this.moduleCache.set(filepath, transformRawData(raw, filepath));
this.updateModuleDeps(filepath, deps);
} catch (e) {
console.error(e);
}
}
};
//#endregion
//#region src/core/mockMiddleware.ts
function mockServerMiddleware(options, server, ws) {
/**
* 加载 mock 文件, 包括监听 mock 文件的依赖文件变化,
* 并注入 vite `define` / `alias`
*/
const compiler = createMockCompiler(options);
compiler.run(!!server);
/**
* 监听 mock 文件是否发生变更,如何配置了 reload 为 true,
* 当发生变更时,通知当前页面进行重新加载
*/
compiler.on("mock:update-end", () => {
if (options.reload) ws?.send({ type: "full-reload" });
});
server?.on("close", () => compiler.close());
/**
* 虽然 config.server.proxy 中有关于 ws 的代理配置,
* 但是由于 vite 内部在启动时,直接对 ws相关的请求,通过 upgrade 事件,发送给 http-proxy
* 的 ws 代理方法。如果插件直接使用 config.server.proxy 中的 ws 配置,
* 就会导致两次 upgrade 事件 对 wss 实例的冲突。
* 由于 vite 内部并没有提供其他的方式跳过 内部 upgrade 的方式,(个人认为也没有必要提供此类方式)
* 所以插件选择了通过插件的配置项 `wsPrefix` 来做 判断的首要条件。
* 当前插件默认会将已配置在 wsPrefix 的值,从 config.server.proxy 的删除,避免发生冲突问题。
*/
mockWebSocket(compiler, server, options);
const middlewares = [];
middlewares.push(
/**
* 在 vite 的开发服务中,由于插件 的 enforce 为 `pre`,
* mock 中间件的执行顺序 早于 vite 内部的 cors 中间件执行,
* 这导致了 vite 默认开启的 cors 对 mock 请求不生效。
* 在一些比如 微前端项目、或者联合项目中,会由于端口不一致而导致跨域问题。
* 所以在这里,使用 cors 中间件 来解决这个问题。
*
* 同时为了使 插件内的 cors 和 vite 的 cors 不产生冲突,并拥有一致的默认行为,
* 也会使用 viteConfig.server.cors 配置,并支持 用户可以对 mock 中的 cors 中间件进行配置。
* 而用户的配置也仅对 mock 的接口生效。
*/
corsMiddleware(compiler, options),
baseMiddleware(compiler, options)
);
return middlewares.filter(Boolean);
}
function corsMiddleware(compiler, { proxies, cors: corsOptions }) {
return !corsOptions ? void 0 : function(req, res, next) {
const { pathname } = urlParse(req.url);
if (!pathname || proxies.length === 0 || !proxies.some((context) => doesProxyContextMatchUrl(context, req.url))) return next();
const mockData = compiler.mockData;
const mockUrl = Object.keys(mockData).find((key) => pathToRegexp(key).test(pathname));
if (!mockUrl) return next();
cors(corsOptions)(req, res, next);
};
}
//#endregion
//#region src/core/define.ts
function viteDefine(config) {
const processNodeEnv = {};
const nodeEnv = process.env.NODE_ENV || config.mode;
Object.assign(processNodeEnv, {
"process.env.NODE_ENV": JSON.stringify(nodeEnv),
"global.process.env.NODE_ENV": JSON.stringify(nodeEnv),
"globalThis.process.env.NODE_ENV": JSON.stringify(nodeEnv)
});
const userDefine = {};
const userDefineEnv = {};
for (const key in config.define) {
const val = config.define[key];
const isMetaEnv = key.startsWith("import.meta.env.");
if (typeof val === "string") {
if (canJsonParse(val)) {
userDefine[key] = val;
if (isMetaEnv) userDefineEnv[key.slice(16)] = val;
}
} else {
userDefine[key] = handleDefineValue(val);
if (isMetaEnv) userDefineEnv[key.slice(16)] = val;
}
}
const importMetaKeys = {};
const importMetaEnvKeys = {};
const importMetaFallbackKeys = {};
importMetaKeys["import.meta.hot"] = `undefined`;
for (const key in config.env) {
const val = JSON.stringify(config.env[key]);
importMetaKeys[`import.meta.env.${key}`] = val;
importMetaEnvKeys[key] = val;
}
importMetaFallbackKeys["import.meta.env"] = `undefined`;
const define = {
...processNodeEnv,
...importMetaKeys,
...userDefine,
...importMetaFallbackKeys
};
if ("import.meta.env" in define) define["import.meta.env"] = serializeDefine({
...importMetaEnvKeys,
...userDefineEnv
});
return define;
}
/**
* Like `JSON.stringify` but keeps raw string values as a literal
* in the generated code. For example: `"window"` would refer to
* the global `window` object directly.
*/
function serializeDefine(define) {
let res = `{`;
const keys = Object.keys(define);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const val = define[key];
res += `${JSON.stringify(key)}: ${handleDefineValue(val)}`;
if (i !== keys.length - 1) res += `, `;
}
return `${res}}`;
}
function handleDefineValue(value) {
if (typeof value === "undefined") return "undefined";
if (typeof value === "string") return value;
return JSON.stringify(value);
}
function canJsonParse(value) {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}
//#endregion
//#region src/core/resolvePluginOptions.ts
function resolvePluginOptions({ prefix = [], wsPrefix = [], cwd, include = ["mock/**/*.mock.{js,ts,cjs,mjs,json,json5}"], exclude = [
"**/node_modules/**",
"**/.vscode/**",
"**/.git/**"
], reload = false, log = "info", cors: cors$1 = true, formidableOptions = {}, build: build$1 = false, cookiesOptions = {}, bodyParserOptions = {}, priority = {} }, config) {
const logger = createLogger("vite:mock", isBoolean(log) ? log ? "info" : "error" : log);
const { httpProxies } = ensureProxies(config.server.proxy || {});
const proxies = uniq([...toArray(prefix), ...httpProxies]);
const wsProxies = toArray(wsPrefix);
if (!proxies.length && !wsProxies.length) logger.warn(`No proxy was configured, mock server will not work. See ${pc.cyan("https://vite-plugin-mock-dev-server.netlify.app/guide/usage")}`);
const enabled = cors$1 === false ? false : config.server.cors !== false;
let corsOptions = {};
if (enabled && config.server.cors !== false) corsOptions = {
...corsOptions,
...typeof config.server.cors === "boolean" ? {} : config.server.cors
};
if (enabled && cors$1 !== false) corsOptions = {
...corsOptions,
...typeof cors$1 === "boolean" ? {} : cors$1
};
const alias = [];
const aliasConfig = config.resolve.alias || [];
if (isArray(aliasConfig)) alias.push(...aliasConfig);
else Object.entries(aliasConfig).forEach(([find, replacement]) => {
alias.push({
find,
replacement
});
});
return {
cwd: cwd || process.cwd(),
include,
exclude,
context: config.root,
reload,
cors: enabled ? corsOptions : false,
cookiesOptions,
log,
formidableOptions: {
multiples: true,
...formidableOptions
},
bodyParserOptions,
priority,
build: build$1 ? Object.assign({
serverPort: 8080,
dist: "mockServer",
log: "error"
}, typeof build$1 === "object" ? build$1 : {}) : false,
proxies,
wsProxies,
logger,
alias,
define: viteDefine(config)
};
}
//#endregion
//#region src/plugin.ts
function mockDevServerPlugin(options = {}) {
const plugins = [serverPlugin(options)];
if (options.build) plugins.push(buildPlugin(options));
return plugins;
}
function buildPlugin(options) {
let viteConfig = {};
let resolvedOptions;
return {
name: "vite-plugin-mock-dev-server-generator",
enforce: "post",
apply: "build",
configResolved(config) {
viteConfig = config;
resolvedOptions = resolvePluginOptions(options, config);
config.logger.warn("");
},
async buildEnd(error) {
if (error || viteConfig.command !== "build") return;
await generateMockServer(this, resolvedOptions);
}
};
}
function serverPlugin(options) {
let resolvedOptions;
return {
name: "vite-plugin-mock-dev-server",
enforce: "pre",
apply: "serve",
config(config) {
const wsPrefix = toArray(options.wsPrefix);
if (wsPrefix.length && config.server?.proxy) {
const proxy = {};
Object.keys(config.server.proxy).forEach((key) => {
if (!wsPrefix.includes(key)) proxy[key] = config.server.proxy[key];
});
config.server.proxy = proxy;
}
recoverRequest(config);
},
configResolved(config) {
resolvedOptions = resolvePluginOptions(options, config);
config.logger.warn("");
},
configureServer({ middlewares, httpServer, ws }) {
const middlewareList = mockServerMiddleware(resolvedOptions, httpServer, ws);
middlewareList.forEach((middleware) => middlewares.use(middleware));
},
configurePreviewServer({ middlewares, httpServer }) {
const middlewareList = mockServerMiddleware(resolvedOptions, httpServer);
middlewareList.forEach((middleware) => middlewares.use(middleware));
}
};
}
//#endregion
//#region src/index.ts
/**
* @deprecated use named export instead
*/
function mockDevServerPluginWithDefaultExportWasDeprecated(options = {}) {
console.warn(`${pc.yellow("[vite-plugin-mock-dev-server]")} ${pc.yellow(pc.bold("WARNING:"))} The plugin default export is ${pc.bold("deprecated")}, it will be removed in next major version, use ${pc.bold("named export")} instead:\n\n ${pc.green("import { mockDevServerPlugin } from \"vite-plugin-mock-dev-server\"")}\n`);
return mockDevServerPlugin(options);
}
//#endregion
export { baseMiddleware, createDefineMock, createLogger, createSSEStream, mockDevServerPluginWithDefaultExportWasDeprecated as default, defineMock, defineMockData, logLevels, mockDevServerPlugin, mockWebSocket, sortByValidator, transformMockData, transformRawData };