UNPKG

rspack-plugin-mock

Version:
907 lines (895 loc) 27.4 kB
// src/core/transform.ts import { isEmptyObject, isFunction, isPlainObject as isPlainObject2, sortBy, toArray } from "@pengzhanbo/utils"; // src/core/utils.ts import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { parse as queryParse } from "node:querystring"; import { fileURLToPath, URL as URL2 } from "node:url"; import Debug from "debug"; import { createFsFromVolume, Volume } from "memfs"; import { match } from "path-to-regexp"; var packageDir = getDirname(import.meta.url); var vfs = createFsFromVolume(new Volume()); 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"; } function getDirname(importMetaUrl) { return path.dirname(fileURLToPath(importMetaUrl)); } var debug = Debug("rspack:mock"); 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 doesProxyContextMatchUrl(context, url, req) { if (typeof context === "function") { return context(url, req); } 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 || {}; } function urlParse(input) { const url = new URL2(input, "http://example.com"); const pathname = decodeURIComponent(url.pathname); const query = queryParse(url.search.replace(/^\?/, "")); return { pathname, query }; } var windowsSlashRE = /\\/g; var isWindows = os.platform() === "win32"; function slash(p) { return p.replace(windowsSlashRE, "/"); } function normalizePath(id) { return path.posix.normalize(isWindows ? slash(id) : id); } function waitingFor(onSuccess, maxRetry = 5) { return function wait(getter, retry = 0) { const value = getter(); if (value) { onSuccess(value); } else if (retry < maxRetry) { setTimeout(() => wait(getter, retry + 1), 100); } }; } // src/core/validator.ts import { isArray, isPlainObject } from "@pengzhanbo/utils"; 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); } 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); } // src/core/transform.ts function transformRawData(rawData) { return rawData.filter((item) => item[0]).map(([raw, __filepath__]) => { let mockConfig; if (raw.default) { if (Array.isArray(raw.default)) { mockConfig = raw.default.map((item) => ({ ...item, __filepath__ })); } else { mockConfig = { ...raw.default, __filepath__ }; } } else if ("url" in raw) { mockConfig = { ...raw, __filepath__ }; } else { mockConfig = []; Object.keys(raw || {}).forEach((key) => { if (Array.isArray(raw[key])) { mockConfig.push(...raw[key].map((item) => ({ ...item, __filepath__ }))); } else { mockConfig.push({ ...raw[key], __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) => isPlainObject2(mock) && mock.enabled !== false && mock.url).forEach((mock) => { const { pathname, query } = urlParse(mock.url); const list2 = 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 }; } } } list2.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; } // src/core/requestRecovery.ts import { Buffer } from "node:buffer"; var requestCollectCache = /* @__PURE__ */ new WeakMap(); function collectRequest(req) { const chunks = []; req.addListener("data", (chunk) => { chunks.push(Buffer.from(chunk)); }); req.addListener("end", () => { if (chunks.length) requestCollectCache.set(req, Buffer.concat(chunks)); }); } function rewriteRequest(proxyReq, req) { const buffer = requestCollectCache.get(req); if (buffer) { requestCollectCache.delete(req); if (!proxyReq.headersSent) proxyReq.setHeader("Content-Length", buffer.byteLength); if (!proxyReq.writableEnded) proxyReq.write(buffer); } } // src/core/baseMiddleware.ts import { Buffer as Buffer2 } from "node:buffer"; import { isArray as isArray3, isEmptyObject as isEmptyObject3, isFunction as isFunction2, random, sleep, timestamp } from "@pengzhanbo/utils"; import Cookies from "cookies"; import HTTP_STATUS from "http-status"; import * as mime from "mime-types"; import { pathToRegexp as pathToRegexp2 } from "path-to-regexp"; import colors from "picocolors"; // src/core/matchingWeight.ts import { isArray as isArray2, isEmptyObject as isEmptyObject2, isString, sortBy as sortBy2, uniq } from "@pengzhanbo/utils"; import { parse, pathToRegexp } from "path-to-regexp"; var tokensCache = {}; function getTokens(rule) { if (tokensCache[rule]) return tokensCache[rule]; const tks = parse(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 match2 of preMatch.filter((v) => v && v.length > 0)) matched = [...matched, ...sortBy2(match2, sortFn).reverse()]; return matched; } function defaultPriority(rules) { const highest = getHighest(rules); return sortBy2(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 && isEmptyObject2(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 (isEmptyObject2(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 } = isArray2(options) ? { rules: options, when: [] } : options; if (lowerRules.includes(matched[0])) { if (when.length === 0 || when.some((path2) => pathToRegexp(path2).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]; } // src/core/parseReqBody.ts import bodyParser from "co-body"; import formidable from "formidable"; async function parseReqBody(req, formidableOptions, bodyParserOptions = {}) { const method = req.method.toUpperCase(); if (["GET", "DELETE", "HEAD"].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(options); return new Promise((resolve, reject) => { form.parse(req, (error, fields, files) => { if (error) { reject(error); return; } resolve({ ...fields, ...files }); }); }); } // 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, req))) { 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 ? colors.underline(colors.bold(m)) : colors.dim(m) ).join(", "); logger.warn( `${colors.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( `${colors.magenta("DEBUG")} ${colors.underline( pathname )} matches: [ ${mockUrls.map( (m) => m === _mockUrl ? colors.underline(colors.bold(m)) : colors.dim(m) ).join(", ")} ] ` ); if (body) { try { const content = isFunction2(body) ? await body(request) : body; await realDelay(startTime, delay); sendData(response, content, type); } catch (e) { logger.error( `${colors.red( `mock error at ${pathname}` )} ${e} at body (${colors.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( `${colors.red( `mock error at ${pathname}` )} ${e} at response (${colors.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 ? isArray3(mock.method) ? mock.method : [mock.method] : ["GET", "POST"]; if (!methods.includes(method)) return false; const hasMock = pathToRegexp2(mock.url).test(pathname); if (hasMock && mock.validator) { const params = parseParams(mock.url, pathname); if (isFunction2(mock.validator)) { return mock.validator({ params, ...request }); } else { try { return validate({ params, ...request }, mock.validator); } catch (e) { const file = mock.__filepath__; logger.error( `${colors.red( `mock error at ${pathname}` )} ${e} at validator (${colors.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 contentType2 = mime.contentType(type) || mime.contentType(mime.lookup(type) || ""); if (contentType2) res.setHeader("Content-Type", contentType2); 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 = isFunction2(headers) ? await headers(req) : headers; Object.keys(raw).forEach((key) => { res.setHeader(key, raw[key]); }); } catch (e) { logger.error( `${colors.red( `mock error at ${req.url.split("?")[0]}` )} ${e} at headers (${colors.underline(filepath)})`, mock.log ); } } async function provideCookies(req, res, mock, logger) { const { cookies } = mock; const filepath = mock.__filepath__; if (!cookies) return; try { const raw = isFunction2(cookies) ? await cookies(req) : cookies; Object.keys(raw).forEach((key) => { const cookie = raw[key]; if (isArray3(cookie)) { const [value, options] = cookie; res.setCookie(key, value, options); } else { res.setCookie(key, cookie); } }); } catch (e) { logger.error( `${colors.red( `mock error at ${req.url.split("?")[0]}` )} ${e} at cookies (${colors.underline(filepath)})`, mock.log ); } } function sendData(res, raw, type) { if (isReadableStream(raw)) { raw.pipe(res); } else if (Buffer2.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" ? Buffer2.from(content) : content); } } async function realDelay(startTime, delay) { if (!delay || typeof delay === "number" && delay <= 0 || isArray3(delay) && delay.length !== 2) { return; } let realDelay2 = 0; if (isArray3(delay)) { const [min, max] = delay; realDelay2 = random(min, max); } else { realDelay2 = delay - (timestamp() - startTime); } if (realDelay2 > 0) await sleep(realDelay2); } 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 = colors.green(decodeURIComponent(pathname)); const format = (prefix, data) => { return !data || isEmptyObject3(data) ? "" : ` ${colors.gray(`${prefix}:`)}${JSON.stringify(data)}`; }; const ms = colors.magenta(colors.bold(method)); const qs = format("query", query); const ps = format("params", params); const bs = format("body", body); const file = ` ${colors.dim(colors.underline(`(${filepath})`))}`; return `${ms} ${pathname}${qs}${ps}${bs}${file}`; } // src/core/mockWebsocket.ts import Cookies2 from "cookies"; import { pathToRegexp as pathToRegexp3 } from "path-to-regexp"; import colors2 from "picocolors"; import { WebSocketServer } from "ws"; function mockWebSocket(compiler, httpServer, { 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 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( `${colors2.red( `WebSocket mock error at ${wss.path}` )} ${e} at setup (${filepath})`, mock.log ); }); } catch (e) { logger.error( `${colors2.red( `WebSocket mock error at ${wss.path}` )} ${e} at setup (${filepath})`, mock.log ); } }; 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("update", ({ 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); } } }); httpServer?.on("upgrade", (req, socket, head) => { const { pathname, query } = urlParse(req.url); if (!pathname || proxies.length === 0 || !proxies.some((context) => doesProxyContextMatchUrl(context, req.url, req))) { return; } const mockData = compiler.mockData; const mockUrl = Object.keys(mockData).find((key) => { return pathToRegexp3(key).test(pathname); }); if (!mockUrl) return; const mock = mockData[mockUrl].find((mock2) => { return mock2.url && mock2.ws && pathToRegexp3(mock2.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 Cookies2(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( `${colors2.magenta(colors2.bold("WebSocket"))} ${colors2.green( req.url )} connected ${colors2.dim(`(${filepath})`)}`, mock.log ); wssContext.connectionList.push({ req: request, ws }); emitConnection(wss, ws, request, wssContext.connectionList); }); }); httpServer?.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 getWss(wssMap, pathname) { let wss = wssMap.get(pathname); if (!wss) wssMap.set(pathname, wss = new WebSocketServer({ noServer: true })); return wss; } function 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); }); } function cleanupRunner(cleanupList) { let cleanup; while (cleanup = cleanupList.shift()) cleanup?.(); } // src/core/logger.ts import { isBoolean } from "@pengzhanbo/utils"; import colors3 from "picocolors"; var 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" ? colors3.magenta(colors3.bold(prefix)) : type === "info" ? colors3.cyan(colors3.bold(prefix)) : type === "warn" ? colors3.yellow(colors3.bold(prefix)) : colors3.red(colors3.bold(prefix)); const format = `${colors3.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; } export { packageDir, vfs, lookupFile, doesProxyContextMatchUrl, urlParse, normalizePath, waitingFor, transformRawData, transformMockData, sortByValidator, rewriteRequest, baseMiddleware, mockWebSocket, logLevels, createLogger };