rspack-plugin-mock
Version:
inject api mock server to development server
488 lines (482 loc) • 14.6 kB
JavaScript
import { c as processRawData, d as normalizePath, f as vfs, h as createMatcher, m as getPackageDeps, p as getPackageDepList, s as processMockData, t as createLogger } from "./logger-CzeuHKAL.js";
import { createRequire } from "node:module";
import path from "node:path";
import process from "node:process";
import { attemptAsync, isBoolean, isPlainObject, toArray, uniq } from "@pengzhanbo/utils";
import * as rspackCore from "@rspack/core";
import fs, { promises } from "node:fs";
import fsp from "node:fs/promises";
import ansis from "ansis";
import { glob } from "tinyglobby";
import isCore from "is-core-module";
import { loadPackageJSONSync } from "local-pkg";
import { pathToFileURL } from "node:url";
import { createHash } from "node:crypto";
import EventEmitter from "node:events";
import chokidar from "chokidar";
//#region src/compiler/createRspackCompiler.ts
const require = createRequire(import.meta.url);
function createCompiler(options, callback) {
const rspackOptions = resolveRspackOptions(options);
const isWatch = rspackOptions.watch === true;
async function handler(err, stats) {
const name = "[rspack:mock]";
const logError = (...args) => {
if (stats) stats.compilation.getLogger(name).error(...args);
else console.error(ansis.red(name), ...args);
};
if (err) {
logError(err.stack || err);
if ("details" in err) logError(err.details);
return;
}
if (stats?.hasErrors()) logError(stats.toJson().errors);
const code = vfs.readFileSync("/output.js", "utf-8");
const externals = [];
if (!isWatch) {
const modules = stats?.toJson().modules || [];
const aliasList = Object.keys(options.alias || {}).map((key) => key.replace(/\$$/g, ""));
for (const { name } of modules) if (name?.startsWith("external")) {
const packageName = normalizePackageName(name);
if (!isCore(packageName) && !aliasList.includes(packageName)) externals.push(normalizePackageName(name));
}
}
await callback({
code,
externals
});
}
const compiler = rspackCore.rspack(rspackOptions);
compiler.outputFileSystem = vfs;
if (!isWatch) compiler.run(async (...args) => {
await handler(...args);
compiler.close(() => {});
});
else compiler.watch({}, handler);
return compiler;
}
function transformWithRspack(options) {
return new Promise((resolve) => {
createCompiler({
...options,
watch: false
}, (result) => {
resolve(result);
});
});
}
function normalizePackageName(name) {
const filepath = name.replace("external ", "").slice(1, -1);
const [scope, packageName] = filepath.split("/");
if (filepath[0] === "@") return `${scope}/${packageName}`;
return scope;
}
function resolveRspackOptions({ cwd, isEsm = true, entryFile, plugins, alias, watch = false }) {
const targets = ["node >= 20.0.0"];
if (alias && "@swc/helpers" in alias) delete alias["@swc/helpers"];
const externals = getPackageDepList(cwd);
const externalPattern = new RegExp(`^(${externals.join("|")})($|/)`, "i");
return {
mode: "production",
context: cwd,
entry: entryFile,
watch,
target: `node${(process.versions.node || "").replace(/\.\d+$/, "")}`,
externalsType: isEsm ? "module" : "commonjs2",
externals: [externalPattern],
resolve: {
alias,
extensions: [
".js",
".ts",
".cjs",
".mjs",
".json5",
".json"
]
},
plugins,
output: {
library: { type: !isEsm ? "commonjs2" : "module" },
filename: "output.js",
module: isEsm,
path: "/"
},
optimization: { minimize: !watch },
node: {
__dirname: false,
__filename: false
},
module: { rules: [
{
test: /\.json5?$/,
loader: require.resolve("#json5-loader"),
type: "javascript/auto"
},
{
test: /\.[cm]?js$/,
use: [{
loader: "builtin:swc-loader",
options: {
jsc: { parser: { syntax: "ecmascript" } },
env: { targets }
}
}]
},
{
test: /\.[cm]?ts$/,
use: [{
loader: "builtin:swc-loader",
options: {
jsc: { parser: { syntax: "typescript" } },
env: { targets }
}
}]
}
] }
};
}
//#endregion
//#region src/compiler/loadFromCode.ts
async function loadFromCode({ filepath, code, isESM, cwd }) {
filepath = path.resolve(cwd, filepath);
const ext = isESM ? ".mjs" : ".cjs";
const filepathTmp = `${filepath}.${getHash(code)}${ext}`;
await promises.writeFile(filepathTmp, code, "utf8");
const [, mod] = await attemptAsync(importDefault, String(pathToFileURL(filepathTmp)));
await attemptAsync(promises.unlink, filepathTmp);
return mod;
}
async function importDefault(filepath) {
const mod = await import(filepath);
return mod.default || mod;
}
function getHash(str) {
return createHash("md5").update(str).digest("hex");
}
//#endregion
//#region src/compiler/mockCompiler.ts
function createMockCompiler(options) {
return new MockCompiler(options);
}
var MockCompiler = class extends EventEmitter {
cwd;
mockWatcher;
entryFile;
deps = [];
isESM = false;
_mockData = {};
watchInfo;
compiler;
constructor(options) {
super();
this.options = options;
this.cwd = options.cwd || process.cwd();
try {
const pkg = loadPackageJSONSync(this.cwd);
this.isESM = pkg?.type === "module";
} catch {}
this.entryFile = path.resolve(process.cwd(), "node_modules/.cache/mock-server/mock-server.ts");
}
get mockData() {
return this._mockData;
}
async run() {
const { include, exclude } = this.options;
const { pattern, ignore, isMatch } = createMatcher(include, exclude);
const files = await glob(pattern, {
ignore,
cwd: path.join(this.cwd, this.options.dir)
});
this.deps = files.map((file) => normalizePath(file));
this.updateMockEntry();
this.watchMockFiles(isMatch);
const { plugins, alias } = this.options;
const options = {
isEsm: this.isESM,
cwd: this.cwd,
plugins,
entryFile: this.entryFile,
alias,
watch: true
};
this.compiler = createCompiler(options, async ({ code }) => {
try {
const result = await loadFromCode({
filepath: "mock.bundle.js",
code,
isESM: this.isESM,
cwd: this.cwd
});
this._mockData = processMockData(processRawData(result));
this.emit("update", this.watchInfo || {});
} catch (e) {
this.options.logger.error(e.stack || e.message);
}
});
}
close() {
this.mockWatcher.close();
this.compiler?.close(() => {});
this.emit("close");
}
updateAlias(alias) {
this.options.alias = {
...this.options.alias,
...alias
};
}
async updateMockEntry() {
await writeMockEntryFile(this.entryFile, this.deps, this.cwd, this.options.dir);
}
watchMockFiles(isMatch) {
const watcher = this.mockWatcher = chokidar.watch(this.options.dir, {
ignoreInitial: true,
cwd: this.cwd,
ignored: (filepath, stats) => {
if (filepath.includes("node_modules")) return true;
return !!stats?.isFile() && !isMatch(filepath);
}
});
watcher.on("add", (filepath) => {
filepath = normalizePath(filepath);
if (isMatch(filepath)) {
this.watchInfo = {
filepath,
type: "add"
};
this.deps = uniq([...this.deps, filepath]);
this.updateMockEntry();
}
});
watcher.on("change", (filepath) => {
filepath = normalizePath(filepath);
if (isMatch(filepath)) this.watchInfo = {
filepath,
type: "change"
};
});
watcher.on("unlink", async (filepath) => {
filepath = normalizePath(filepath);
if (isMatch(filepath)) {
this.watchInfo = {
filepath,
type: "unlink"
};
this.deps = this.deps.filter((dep) => dep !== filepath);
this.updateMockEntry();
}
});
}
};
//#endregion
//#region package.json
var name = "rspack-plugin-mock";
var version = "2.1.0";
//#endregion
//#region src/build/packageJson.ts
function generatePackageJson(options, externals) {
const deps = getPackageDeps(options.context);
const exclude = [
name,
"connect",
"cors"
];
const mockPkg = {
name: "mock-server",
type: "module",
scripts: { start: "node index.js" },
dependencies: {
connect: "^3.7.0",
[name]: `^${version}`,
cors: "^2.8.5"
}
};
externals.filter((dep) => !exclude.includes(dep)).forEach((dep) => {
mockPkg.dependencies[dep] = deps[dep] || "latest";
});
return JSON.stringify(mockPkg, null, 2);
}
//#endregion
//#region src/build/serverEntryCode.ts
function generatorServerEntryCode({ proxies, wsPrefix, cookiesOptions, bodyParserOptions, priority, build }) {
const { serverPort, log } = build;
return `import { createServer } from 'node:http';
import connect from 'connect';
import corsMiddleware from 'cors';
import {
baseMiddleware,
createLogger,
mockWebSocket,
processMockData,
processRawData
} from 'rspack-plugin-mock/server';
import rawData 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(toArray(wsPrefix))};
const cookiesOptions = ${JSON.stringify(cookiesOptions)};
const bodyParserOptions = ${JSON.stringify(bodyParserOptions)};
const priority = ${JSON.stringify(priority)};
const mockConfig = {
mockData: processMockData(processRawData(rawData)),
on: () => {},
};
mockWebSocket(mockConfig, server, { wsProxies, cookiesOptions, logger });
app.use(corsMiddleware());
app.use(baseMiddleware(mockConfig, {
formidableOptions: { multiples: true },
proxies,
priority,
cookiesOptions,
bodyParserOptions,
logger,
}));
server.listen(${serverPort});
console.log('listen: http://localhost:${serverPort}');
`;
}
//#endregion
//#region src/build/writeEntryFile.ts
async function writeMockEntryFile(entryFile, files, cwd, dir) {
const importers = [];
const exporters = [];
for (const [index, filepath] of files.entries()) {
const relative = normalizePath(path.join(dir, filepath));
const file = normalizePath(path.join(cwd, relative));
importers.push(`import * as m${index} from '${file}'`);
exporters.push(`[m${index}, '${relative}']`);
}
const code = `${importers.join("\n")}\n\nexport default [\n ${exporters.join(",\n ")}\n]`;
const dirname = path.dirname(entryFile);
if (!fs.existsSync(dirname)) await fsp.mkdir(dirname, { recursive: true });
await fsp.writeFile(entryFile, code, "utf8");
}
//#endregion
//#region src/build/generate.ts
async function buildMockServer(options, outputDir) {
const buildOptions = options.build;
const entryFile = path.resolve(process.cwd(), "node_modules/.cache/mock-server/mock-server.ts");
const { pattern, ignore } = createMatcher(options.include, options.exclude);
await writeMockEntryFile(entryFile, await glob(pattern, {
ignore,
cwd: path.join(options.cwd, options.dir)
}), options.cwd, options.dir);
const { code, externals } = await transformWithRspack({
entryFile,
cwd: options.cwd,
plugins: options.plugins,
alias: options.alias
});
await fsp.unlink(entryFile);
const outputList = [
{
filename: "mock-data.js",
source: code
},
{
filename: "index.js",
source: generatorServerEntryCode(options)
},
{
filename: "package.json",
source: generatePackageJson(options, externals)
}
];
if (options.record.enabled && buildOptions.includeRecord) {
const files = await glob(path.join(options.record.dir, "**/*.json"), {
cwd: options.cwd,
dot: true
});
for (const file of files) outputList.push({
filename: path.join(outputDir, file),
source: await fsp.readFile(path.join(options.cwd, file), "utf-8")
});
}
const dist = path.resolve(outputDir, options.build.dist);
options.logger.info(`${ansis.green("✓")} generate mock server in ${ansis.cyan(path.relative(process.cwd(), dist))}`);
if (!fs.existsSync(dist)) await fsp.mkdir(dist, { recursive: true });
for (const { filename, source } of outputList) {
await fsp.writeFile(path.join(dist, filename), source, "utf8");
const sourceSize = (source.length / 1024).toFixed(2);
const space = filename.length < 24 ? " ".repeat(24 - filename.length) : "";
options.logger.info(` ${ansis.green(filename)}${space}${ansis.bold.dim(`${sourceSize} kB`)}`);
}
}
//#endregion
//#region src/core/options.ts
function resolvePluginOptions({ prefix = [], wsPrefix = [], cwd, dir = "mock", include = ["**/*.mock.{js,ts,cjs,mjs,json,json5}"], exclude = [], reload = false, log = "info", cors = true, formidableOptions = {}, build = false, cookiesOptions = {}, bodyParserOptions = {}, priority = {}, activeScene = [], record = false, replay }, { alias, context, plugins, proxies: rawProxies }) {
const logger = createLogger("rspack:mock", isBoolean(log) ? log ? "info" : "error" : log);
const proxies = [...toArray(prefix), ...rawProxies];
const wsProxies = toArray(wsPrefix);
if (!proxies.length && !wsProxies.length) logger.warn(`No proxy was configured, mock server will not work. See ${ansis.cyan("https://vite-plugin-mock-dev-server.netlify.app/guide/usage")}`);
const enabled = !!cors;
let corsOptions = {};
if (enabled && isPlainObject(cors)) corsOptions = {
...corsOptions,
...cors
};
cwd = cwd || context || process.cwd();
const resolvedRecord = resolveRecordOptions(cwd, dir, record);
return {
enabled: true,
prefix,
wsPrefix,
cwd,
dir,
include,
exclude,
reload,
cors: enabled ? corsOptions : false,
cookiesOptions,
log,
formidableOptions: {
multiples: true,
...formidableOptions
},
bodyParserOptions,
priority,
build: build ? {
serverPort: 8080,
dist: "mockServer",
log: "error",
...typeof build === "object" ? build : {}
} : false,
alias,
plugins,
proxies,
wsProxies,
logger,
activeScene: toArray(activeScene),
record: resolvedRecord,
replay: replay ?? resolvedRecord.enabled ?? false
};
}
/**
* Resolve record options
*
* 解析录制配置
*
* @param cwd - Current working directory / 当前工作目录
* @param dir - Mock context directory / 模拟上下文目录
* @param record - Record options / 录制配置
* @returns Resolved record options / 解析后的录制配置
*/
function resolveRecordOptions(cwd, dir, record) {
const recordOptions = typeof record === "boolean" ? { enabled: record } : record;
const expires = recordOptions?.expires ?? 0;
return {
enabled: recordOptions?.enabled ?? false,
cwd,
dir: path.join(dir, recordOptions?.dir || ".recordings"),
overwrite: recordOptions?.overwrite ?? true,
status: toArray(recordOptions?.status).map(Number),
expires: expires === 0 ? Number.MAX_SAFE_INTEGER : expires * 1e3,
gitignore: recordOptions?.gitignore ?? true,
filter: recordOptions?.filter || (() => true)
};
}
//#endregion
export { buildMockServer as n, createMockCompiler as r, resolvePluginOptions as t };