UNPKG

vite-plugin-mock-dev-server

Version:
727 lines (717 loc) 25.2 kB
import { isArray, isBoolean, isEmptyObject, isFunction, isPlainObject, isString, random, sleep, sortBy, timestamp, toArray, uniq } from "./dist-CAA1v47s.js"; import pc from "picocolors"; import fs from "node:fs"; import path from "node:path"; import { URL as URL$1 } from "node:url"; import os from "node:os"; import { parse } from "node:querystring"; import Debug from "debug"; import { match, parse as parse$1, pathToRegexp } from "path-to-regexp"; import { Buffer } from "node:buffer"; import Cookies from "cookies"; import HTTP_STATUS from "http-status"; import * as mime from "mime-types"; import bodyParser from "co-body"; import formidable from "formidable"; import { WebSocketServer } from "ws"; //#region src/core/utils.ts function isStream(stream) { return stream !== null && typeof stream === "object" && typeof stream.pipe === "function"; } function isReadableStream(stream) { return isStream(stream) && stream.readable !== false && typeof stream._read === "function" && typeof stream._readableState === "object"; } const debug = Debug("vite:mock-dev-server"); function lookupFile(dir, formats, options) { for (const format of formats) { const fullPath = path.join(dir, format); if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { const result = options?.pathOnly ? fullPath : fs.readFileSync(fullPath, "utf-8"); if (!options?.predicate || options.predicate(result)) return result; } } const parentDir = path.dirname(dir); if (parentDir !== dir && (!options?.rootDir || parentDir.startsWith(options?.rootDir))) return lookupFile(parentDir, formats, options); } function ensureProxies(serverProxy = {}) { const httpProxies = []; const wsProxies = []; Object.keys(serverProxy).forEach((key) => { const value = serverProxy[key]; if (typeof value === "string" || !value.ws && !value.target?.toString().startsWith("ws:") && !value.target?.toString().startsWith("wss:")) httpProxies.push(key); else wsProxies.push(key); }); return { httpProxies, wsProxies }; } function doesProxyContextMatchUrl(context, url) { return context[0] === "^" && new RegExp(context).test(url) || url.startsWith(context); } function parseParams(pattern, url) { const urlMatch = match(pattern, { decode: decodeURIComponent })(url) || { params: {} }; return urlMatch.params || {}; } /** * nodejs 从 19.0.0 开始 弃用 url.parse,因此使用 url.parse 来解析 可能会报错, * 使用 URL 来解析 */ function urlParse(input) { const url = new URL$1(input, "http://example.com"); const pathname = decodeURIComponent(url.pathname); const query = parse(url.search.replace(/^\?/, "")); return { pathname, query }; } const windowsSlashRE = /\\/g; const isWindows = os.platform() === "win32"; function slash(p) { return p.replace(windowsSlashRE, "/"); } function normalizePath(id) { return path.posix.normalize(isWindows ? slash(id) : id); } //#endregion //#region src/core/matchingWeight.ts const tokensCache = {}; function getTokens(rule) { if (tokensCache[rule]) return tokensCache[rule]; const tks = parse$1(rule); const tokens = []; for (const tk of tks) if (!isString(tk)) tokens.push(tk); else { const hasPrefix = tk[0] === "/"; const subTks = hasPrefix ? tk.slice(1).split("/") : tk.split("/"); tokens.push(`${hasPrefix ? "/" : ""}${subTks[0]}`, ...subTks.slice(1).map((t) => `/${t}`)); } tokensCache[rule] = tokens; return tokens; } function getHighest(rules) { let weights = rules.map((rule) => getTokens(rule).length); weights = weights.length === 0 ? [1] : weights; return Math.max(...weights) + 2; } function sortFn(rule) { const tokens = getTokens(rule); let w = 0; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (!isString(token)) w += 10 ** (i + 1); w += 10 ** (i + 1); } return w; } function preSort(rules) { let matched = []; const preMatch = []; for (const rule of rules) { const tokens = getTokens(rule); const len = tokens.filter((token) => typeof token !== "string").length; if (!preMatch[len]) preMatch[len] = []; preMatch[len].push(rule); } for (const match$1 of preMatch.filter((v) => v && v.length > 0)) matched = [...matched, ...sortBy(match$1, sortFn).reverse()]; return matched; } function defaultPriority(rules) { const highest = getHighest(rules); return sortBy(rules, (rule) => { const tokens = getTokens(rule); const dym = tokens.filter((token) => typeof token !== "string"); if (dym.length === 0) return 0; let weight = dym.length; let exp = 0; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; const isDynamic = !isString(token); const { pattern = "", modifier, prefix, name } = isDynamic ? token : {}; const isGlob = pattern && pattern.includes(".*"); const isSlash = prefix === "/"; const isNamed = isString(name); exp += isDynamic && isSlash ? 1 : 0; if (i === tokens.length - 1 && isGlob) weight += 5 * 10 ** (tokens.length === 1 ? highest + 1 : highest); else if (isGlob) weight += 3 * 10 ** (highest - 1); else if (pattern) if (isSlash) weight += (isNamed ? 2 : 1) * 10 ** (exp + 1); else weight -= 1 * 10 ** exp; if (modifier === "+") weight += 1 * 10 ** (highest - 1); if (modifier === "*") weight += 1 * 10 ** (highest - 1) + 1; if (modifier === "?") weight += 1 * 10 ** (exp + (isSlash ? 1 : 0)); } return weight; }); } function matchingWeight(rules, url, priority) { let matched = defaultPriority(preSort(rules.filter((rule) => pathToRegexp(rule).test(url)))); const { global = [], special = {} } = priority; if (global.length === 0 && isEmptyObject(special) || matched.length === 0) return matched; const [statics, dynamics] = twoPartMatch(matched); const globalMatch = global.filter((rule) => dynamics.includes(rule)); if (globalMatch.length > 0) matched = uniq([ ...statics, ...globalMatch, ...dynamics ]); if (isEmptyObject(special)) return matched; const specialRule = Object.keys(special).filter((rule) => matched.includes(rule))[0]; if (!specialRule) return matched; const options = special[specialRule]; const { rules: lowerRules, when } = isArray(options) ? { rules: options, when: [] } : options; if (lowerRules.includes(matched[0])) { if (when.length === 0 || when.some((path$1) => pathToRegexp(path$1).test(url))) matched = uniq([specialRule, ...matched]); } return matched; } function twoPartMatch(rules) { const statics = []; const dynamics = []; for (const rule of rules) { const tokens = getTokens(rule); const dym = tokens.filter((token) => typeof token !== "string"); if (dym.length > 0) dynamics.push(rule); else statics.push(rule); } return [statics, dynamics]; } //#endregion //#region src/core/parseReqBody.ts const DEFAULT_FORMIDABLE_OPTIONS = { keepExtensions: true, filename(name, ext, part) { return part?.originalFilename || `${name}.${Date.now()}${ext ? `.${ext}` : ""}`; } }; async function parseReqBody(req, formidableOptions, bodyParserOptions = {}) { const method = req.method.toUpperCase(); if (["HEAD", "OPTIONS"].includes(method)) return void 0; const type = req.headers["content-type"]?.toLocaleLowerCase() || ""; const { limit, formLimit, jsonLimit, textLimit,...rest } = bodyParserOptions; try { if (type.startsWith("application/json")) return await bodyParser.json(req, { limit: jsonLimit || limit, ...rest }); if (type.startsWith("application/x-www-form-urlencoded")) return await bodyParser.form(req, { limit: formLimit || limit, ...rest }); if (type.startsWith("text/plain")) return await bodyParser.text(req, { limit: textLimit || limit, ...rest }); if (type.startsWith("multipart/form-data")) return await parseMultipart(req, formidableOptions); } catch (e) { console.error(e); } return void 0; } async function parseMultipart(req, options) { const form = formidable({ ...DEFAULT_FORMIDABLE_OPTIONS, ...options }); return new Promise((resolve, reject) => { form.parse(req, (error, fields, files) => { if (error) { reject(error); return; } resolve({ ...fields, ...files }); }); }); } //#endregion //#region src/core/requestRecovery.ts const cache = /* @__PURE__ */ new WeakMap(); function collectRequest(req) { const chunks = []; req.addListener("data", (chunk) => { chunks.push(Buffer.from(chunk)); }); req.addListener("end", () => { if (chunks.length) cache.set(req, Buffer.concat(chunks)); }); } /** * vite 在 proxy 配置中,允许通过 configure 访问 http-proxy 实例, * 通过 http-proxy 的 proxyReq 事件,重新写入请求流 */ function recoverRequest(config) { if (!config.server) return; const proxies = config.server.proxy || {}; Object.keys(proxies).forEach((key) => { const target = proxies[key]; const options = typeof target === "string" ? { target } : target; if (options.ws) return; const { configure,...rest } = options; proxies[key] = { ...rest, configure(proxy, options$1) { configure?.(proxy, options$1); proxy.on("proxyReq", (proxyReq, req) => { const buffer = cache.get(req); if (buffer) { cache.delete(req); /** * 使用 http-proxy 的 agent 配置会提前写入代理请求流 * https://github.com/http-party/node-http-proxy/issues/1287 */ if (!proxyReq.headersSent) proxyReq.setHeader("Content-Length", buffer.byteLength); if (!proxyReq.writableEnded) proxyReq.write(buffer); } }); } }; }); } //#endregion //#region src/core/validator.ts function validate(request, validator) { return isObjectSubset(request.headers, validator.headers) && isObjectSubset(request.body, validator.body) && isObjectSubset(request.params, validator.params) && isObjectSubset(request.query, validator.query) && isObjectSubset(request.refererQuery, validator.refererQuery); } /** * Checks if target object is a subset of source object. * That is, all properties and their corresponding values in target exist in source. * * 深度比较两个对象之间,target 是否属于 source 的子集, * 即 target 的所有属性和对应的值,都在 source 中, */ function isObjectSubset(source, target) { if (!target) return true; for (const key in target) if (!isIncluded(source[key], target[key])) return false; return true; } function isIncluded(source, target) { if (isArray(source) && isArray(target)) { const seen = /* @__PURE__ */ new Set(); return target.every((ti) => source.some((si, i) => { if (seen.has(i)) return false; const included = isIncluded(si, ti); if (included) seen.add(i); return included; })); } if (isPlainObject(source) && isPlainObject(target)) return isObjectSubset(source, target); return Object.is(source, target); } //#endregion //#region src/core/baseMiddleware.ts function baseMiddleware(compiler, { formidableOptions = {}, bodyParserOptions = {}, proxies, cookiesOptions, logger, priority = {} }) { return async function(req, res, next) { const startTime = timestamp(); const { query, pathname } = urlParse(req.url); if (!pathname || proxies.length === 0 || !proxies.some((context) => doesProxyContextMatchUrl(context, req.url))) return next(); const mockData = compiler.mockData; const mockUrls = matchingWeight(Object.keys(mockData), pathname, priority); if (mockUrls.length === 0) return next(); collectRequest(req); const { query: refererQuery } = urlParse(req.headers.referer || ""); const reqBody = await parseReqBody(req, formidableOptions, bodyParserOptions); const cookies = new Cookies(req, res, cookiesOptions); const getCookie = cookies.get.bind(cookies); const method = req.method.toUpperCase(); let mock; let _mockUrl; for (const mockUrl of mockUrls) { mock = fineMock(mockData[mockUrl], logger, { pathname, method, request: { query, refererQuery, body: reqBody, headers: req.headers, getCookie } }); if (mock) { _mockUrl = mockUrl; break; } } if (!mock) { const matched = mockUrls.map((m) => m === _mockUrl ? pc.underline(pc.bold(m)) : pc.dim(m)).join(", "); logger.warn(`${pc.green(pathname)} matches ${matched} , but mock data is not found.`); return next(); } const request = req; const response = res; request.body = reqBody; request.query = query; request.refererQuery = refererQuery; request.params = parseParams(mock.url, pathname); request.getCookie = getCookie; response.setCookie = cookies.set.bind(cookies); const { body, delay, type = "json", response: responseFn, status = 200, statusText, log: logLevel, __filepath__: filepath } = mock; responseStatus(response, status, statusText); await provideHeaders(request, response, mock, logger); await provideCookies(request, response, mock, logger); logger.info(requestLog(request, filepath), logLevel); logger.debug(`${pc.magenta("DEBUG")} ${pc.underline(pathname)} matches: [ ${mockUrls.map((m) => m === _mockUrl ? pc.underline(pc.bold(m)) : pc.dim(m)).join(", ")} ]\n`); if (body) { try { const content = isFunction(body) ? await body(request) : body; await realDelay(startTime, delay); sendData(response, content, type); } catch (e) { logger.error(`${pc.red(`mock error at ${pathname}`)}\n${e}\n at body (${pc.underline(filepath)})`, logLevel); responseStatus(response, 500); res.end(""); } return; } if (responseFn) { try { await realDelay(startTime, delay); await responseFn(request, response, next); } catch (e) { logger.error(`${pc.red(`mock error at ${pathname}`)}\n${e}\n at response (${pc.underline(filepath)})`, logLevel); responseStatus(response, 500); res.end(""); } return; } res.end(""); }; } function fineMock(mockList, logger, { pathname, method, request }) { return mockList.find((mock) => { if (!pathname || !mock || !mock.url || mock.ws === true) return false; const methods = mock.method ? isArray(mock.method) ? mock.method : [mock.method] : ["GET", "POST"]; if (!methods.includes(method)) return false; const hasMock = pathToRegexp(mock.url).test(pathname); if (hasMock && mock.validator) { const params = parseParams(mock.url, pathname); if (isFunction(mock.validator)) return mock.validator({ params, ...request }); else try { return validate({ params, ...request }, mock.validator); } catch (e) { const file = mock.__filepath__; logger.error(`${pc.red(`mock error at ${pathname}`)}\n${e}\n at validator (${pc.underline(file)})`, mock.log); return false; } } return hasMock; }); } function responseStatus(response, status = 200, statusText) { response.statusCode = status; response.statusMessage = statusText || getHTTPStatusText(status); } async function provideHeaders(req, res, mock, logger) { const { headers, type = "json" } = mock; const filepath = mock.__filepath__; const contentType = mime.contentType(type) || mime.contentType(mime.lookup(type) || ""); if (contentType) res.setHeader("Content-Type", contentType); res.setHeader("Cache-Control", "no-cache,max-age=0"); res.setHeader("X-Mock-Power-By", "vite-plugin-mock-dev-server"); if (filepath) res.setHeader("X-File-Path", filepath); if (!headers) return; try { const raw = isFunction(headers) ? await headers(req) : headers; Object.keys(raw).forEach((key) => { res.setHeader(key, raw[key]); }); } catch (e) { logger.error(`${pc.red(`mock error at ${req.url.split("?")[0]}`)}\n${e}\n at headers (${pc.underline(filepath)})`, mock.log); } } async function provideCookies(req, res, mock, logger) { const { cookies } = mock; const filepath = mock.__filepath__; if (!cookies) return; try { const raw = isFunction(cookies) ? await cookies(req) : cookies; Object.keys(raw).forEach((key) => { const cookie = raw[key]; if (isArray(cookie)) { const [value, options] = cookie; res.setCookie(key, value, options); } else res.setCookie(key, cookie); }); } catch (e) { logger.error(`${pc.red(`mock error at ${req.url.split("?")[0]}`)}\n${e}\n at cookies (${pc.underline(filepath)})`, mock.log); } } function sendData(res, raw, type) { if (isReadableStream(raw)) raw.pipe(res); else if (Buffer.isBuffer(raw)) res.end(type === "text" || type === "json" ? raw.toString("utf-8") : raw); else { const content = typeof raw === "string" ? raw : JSON.stringify(raw); res.end(type === "buffer" ? Buffer.from(content) : content); } } async function realDelay(startTime, delay) { if (!delay || typeof delay === "number" && delay <= 0 || isArray(delay) && delay.length !== 2) return; let realDelay$1 = 0; if (isArray(delay)) { const [min, max] = delay; realDelay$1 = random(min, max); } else realDelay$1 = delay - (timestamp() - startTime); if (realDelay$1 > 0) await sleep(realDelay$1); } function getHTTPStatusText(status) { return HTTP_STATUS[status] || "Unknown"; } function requestLog(request, filepath) { const { url, method, query, params, body } = request; let { pathname } = new URL(url, "http://example.com"); pathname = pc.green(decodeURIComponent(pathname)); const format = (prefix, data) => { return !data || isEmptyObject(data) ? "" : ` ${pc.gray(`${prefix}:`)}${JSON.stringify(data)}`; }; const ms = pc.magenta(pc.bold(method)); const qs = format("query", query); const ps = format("params", params); const bs = format("body", body); const file = ` ${pc.dim(pc.underline(`(${filepath})`))}`; return `${ms} ${pathname}${qs}${ps}${bs}${file}`; } //#endregion //#region src/core/transform.ts function transformRawData(raw, __filepath__) { let mockConfig; if (isArray(raw)) mockConfig = raw.map((item) => ({ ...item, __filepath__ })); else if ("url" in raw) mockConfig = { ...raw, __filepath__ }; else { mockConfig = []; Object.keys(raw).forEach((key) => { const data = raw[key]; if (isArray(data)) mockConfig.push(...data.map((item) => ({ ...item, __filepath__ }))); else mockConfig.push({ ...data, __filepath__ }); }); } return mockConfig; } function transformMockData(mockList) { const list = []; for (const [, handle] of mockList.entries()) if (handle) list.push(...toArray(handle)); const mocks = {}; list.filter((mock) => isPlainObject(mock) && mock.enabled !== false && mock.url).forEach((mock) => { const { pathname, query } = urlParse(mock.url); const list$1 = mocks[pathname] ??= []; const current = { ...mock, url: pathname }; if (current.ws !== true) { const validator = current.validator; if (!isEmptyObject(query)) if (isFunction(validator)) current.validator = function(request) { return isObjectSubset(request.query, query) && validator(request); }; else if (validator) { current.validator = { ...validator }; current.validator.query = current.validator.query ? { ...query, ...current.validator.query } : query; } else current.validator = { query }; } list$1.push(current); }); Object.keys(mocks).forEach((key) => { mocks[key] = sortByValidator(mocks[key]); }); return mocks; } function sortByValidator(mocks) { return sortBy(mocks, (item) => { if (item.ws === true) return 0; const { validator } = item; if (!validator || isEmptyObject(validator)) return 2; if (isFunction(validator)) return 0; const count = Object.keys(validator).reduce((prev, key) => prev + keysCount(validator[key]), 0); return 1 / count; }); } function keysCount(obj) { if (!obj) return 0; return Object.keys(obj).length; } //#endregion //#region src/core/ws.ts /** * mock websocket */ function mockWebSocket(compiler, server, { wsProxies: proxies, cookiesOptions, logger }) { const hmrMap = /* @__PURE__ */ new Map(); const poolMap = /* @__PURE__ */ new Map(); const wssContextMap = /* @__PURE__ */ new WeakMap(); const getWssMap = (mockUrl) => { let wssMap = poolMap.get(mockUrl); if (!wssMap) poolMap.set(mockUrl, wssMap = /* @__PURE__ */ new Map()); return wssMap; }; const getWss = (wssMap, pathname) => { let wss = wssMap.get(pathname); if (!wss) wssMap.set(pathname, wss = new WebSocketServer({ noServer: true })); return wss; }; const addHmr = (filepath, mockUrl) => { let urlList = hmrMap.get(filepath); if (!urlList) hmrMap.set(filepath, urlList = /* @__PURE__ */ new Set()); urlList.add(mockUrl); }; const setupWss = (wssMap, wss, mock, context, pathname, filepath) => { try { mock.setup?.(wss, context); wss.on("close", () => wssMap.delete(pathname)); wss.on("error", (e) => { logger.error(`${pc.red(`WebSocket mock error at ${wss.path}`)}\n${e}\n at setup (${filepath})`, mock.log); }); } catch (e) { logger.error(`${pc.red(`WebSocket mock error at ${wss.path}`)}\n${e}\n at setup (${filepath})`, mock.log); } }; const emitConnection = (wss, ws, req, connectionList) => { wss.emit("connection", ws, req); ws.on("close", () => { const i = connectionList.findIndex((item) => item.ws === ws); if (i !== -1) connectionList.splice(i, 1); }); }; const restartWss = (wssMap, wss, mock, pathname, filepath) => { const { cleanupList, connectionList, context } = wssContextMap.get(wss); cleanupRunner(cleanupList); connectionList.forEach(({ ws }) => ws.removeAllListeners()); wss.removeAllListeners(); setupWss(wssMap, wss, mock, context, pathname, filepath); connectionList.forEach(({ ws, req }) => emitConnection(wss, ws, req, connectionList)); }; compiler.on?.("mock:update-end", (filepath) => { if (!hmrMap.has(filepath)) return; const mockUrlList = hmrMap.get(filepath); if (!mockUrlList) return; for (const mockUrl of mockUrlList.values()) for (const mock of compiler.mockData[mockUrl]) { if (!mock.ws || mock.__filepath__ !== filepath) return; const wssMap = getWssMap(mockUrl); for (const [pathname, wss] of wssMap.entries()) restartWss(wssMap, wss, mock, pathname, filepath); } }); server?.on("upgrade", (req, socket, head) => { const { pathname, query } = urlParse(req.url); if (!pathname || proxies.length === 0 || !proxies.some((context) => doesProxyContextMatchUrl(context, req.url))) return; const mockData = compiler.mockData; const mockUrl = Object.keys(mockData).find((key) => { return pathToRegexp(key).test(pathname); }); if (!mockUrl) return; const mock = mockData[mockUrl].find((mock$1) => { return mock$1.url && mock$1.ws && pathToRegexp(mock$1.url).test(pathname); }); if (!mock) return; const filepath = mock.__filepath__; addHmr(filepath, mockUrl); const wssMap = getWssMap(mockUrl); const wss = getWss(wssMap, pathname); let wssContext = wssContextMap.get(wss); if (!wssContext) { const cleanupList = []; const context = { onCleanup: (cleanup) => cleanupList.push(cleanup) }; wssContext = { cleanupList, context, connectionList: [] }; wssContextMap.set(wss, wssContext); setupWss(wssMap, wss, mock, context, pathname, filepath); } const request = req; const cookies = new Cookies(req, req, cookiesOptions); const { query: refererQuery } = urlParse(req.headers.referer || ""); request.query = query; request.refererQuery = refererQuery; request.params = parseParams(mockUrl, pathname); request.getCookie = cookies.get.bind(cookies); wss.handleUpgrade(request, socket, head, (ws) => { logger.info(`${pc.magenta(pc.bold("WebSocket"))} ${pc.green(req.url)} connected ${pc.dim(`(${filepath})`)}`, mock.log); wssContext.connectionList.push({ req: request, ws }); emitConnection(wss, ws, request, wssContext.connectionList); }); }); server?.on("close", () => { for (const wssMap of poolMap.values()) { for (const wss of wssMap.values()) { const wssContext = wssContextMap.get(wss); cleanupRunner(wssContext.cleanupList); wss.close(); } wssMap.clear(); } poolMap.clear(); hmrMap.clear(); }); } function cleanupRunner(cleanupList) { let cleanup; while (cleanup = cleanupList.shift()) cleanup?.(); } //#endregion //#region src/core/logger.ts const logLevels = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 }; function createLogger(prefix, defaultLevel = "info") { prefix = `[${prefix}]`; function output(type, msg, level) { level = isBoolean(level) ? level ? defaultLevel : "error" : level; const thresh = logLevels[level]; if (thresh >= logLevels[type]) { const method = type === "info" || type === "debug" ? "log" : type; const tag = type === "debug" ? pc.magenta(pc.bold(prefix)) : type === "info" ? pc.cyan(pc.bold(prefix)) : type === "warn" ? pc.yellow(pc.bold(prefix)) : pc.red(pc.bold(prefix)); const format = `${pc.dim((/* @__PURE__ */ new Date()).toLocaleTimeString())} ${tag} ${msg}`; console[method](format); } } const logger = { debug(msg, level = defaultLevel) { output("debug", msg, level); }, info(msg, level = defaultLevel) { output("info", msg, level); }, warn(msg, level = defaultLevel) { output("warn", msg, level); }, error(msg, level = defaultLevel) { output("error", msg, level); } }; return logger; } //#endregion export { baseMiddleware, createLogger, debug, doesProxyContextMatchUrl, ensureProxies, logLevels, lookupFile, mockWebSocket, normalizePath, recoverRequest, sortByValidator, transformMockData, transformRawData, urlParse };