whistle.mockbubu 2.2.7 → 2.2.9-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +1 -1
- package/lib/config/rule-collector.js +1 -1
- package/lib/core/res-write/index.js +151 -0
- package/lib/core/res-write/response-rewriter.js +258 -0
- package/lib/core/rulesServer.js +15 -4
- package/lib/core/server-entry/capture-handler.js +4 -186
- package/lib/core/server-entry/response-handler.js +9 -348
- package/lib/core/server-entry/server.js +5 -13
- package/lib/core/shared/buffer.js +23 -0
- package/lib/core/shared/capture.js +203 -0
- package/lib/core/shared/concurrent.js +72 -0
- package/lib/core/shared/initializers.js +93 -0
- package/lib/core/shared/mock-response.js +306 -0
- package/lib/utils/utils.js +1 -1
- package/package.json +1 -1
- package/public/js/app.js +236 -32
- package/public/js/app.js.map +1 -1
- package/rules.txt +10 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件名: initializers.js
|
|
3
|
+
* 功能: 钩子共享初始化器(共用模块)
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 创建并缓存 StorageAdapter / GroupManager
|
|
6
|
+
* - 初始化 pluginModeManager 单例
|
|
7
|
+
* - 保证幂等:多个钩子(server / resWrite)调用只初始化一次
|
|
8
|
+
*
|
|
9
|
+
* 设计说明:
|
|
10
|
+
* - memoryBuffer 与 pluginModeManager 本身是单例,直接复用
|
|
11
|
+
* - storageAdapter / groupManager 按 storage 引用缓存,避免重复创建
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const GroupManager = require('../../storage/group-manager')
|
|
15
|
+
const StorageAdapter = require('../../storage/storage-adapter')
|
|
16
|
+
const memoryBuffer = require('../memory-buffer/shared-instance')
|
|
17
|
+
const pluginModeManager = require('../plugin-mode-manager')
|
|
18
|
+
|
|
19
|
+
// 按 storage 引用缓存初始化结果,保证幂等
|
|
20
|
+
const cache = new WeakMap()
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 获取共享实例(未初始化时返回 undefined)
|
|
24
|
+
* @param {Object} options - Whistle 钩子 options
|
|
25
|
+
* @returns {Object} { storageAdapter, groupManager, memoryBuffer, pluginModeManager, storage }
|
|
26
|
+
*/
|
|
27
|
+
function getShared(options) {
|
|
28
|
+
const storage = options && options.storage
|
|
29
|
+
if (!storage) {
|
|
30
|
+
return { memoryBuffer, pluginModeManager }
|
|
31
|
+
}
|
|
32
|
+
const cached = cache.get(storage)
|
|
33
|
+
if (cached) return cached
|
|
34
|
+
// 未初始化时返回仅含 storage 的占位,调用方应 await initShared 后再取
|
|
35
|
+
return { storage, memoryBuffer, pluginModeManager }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 初始化共享存储/组管理器(幂等)
|
|
40
|
+
* @param {Object} options - Whistle 钩子 options
|
|
41
|
+
* @returns {Promise<Object>} { storageAdapter, groupManager, memoryBuffer, pluginModeManager, storage }
|
|
42
|
+
*/
|
|
43
|
+
async function initShared(options) {
|
|
44
|
+
const storage = options && options.storage
|
|
45
|
+
if (!storage) {
|
|
46
|
+
throw new Error('[mockbubu] initShared: options.storage 缺失')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cached = cache.get(storage)
|
|
50
|
+
if (cached && cached._initialized) {
|
|
51
|
+
return cached
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const storageAdapter = new StorageAdapter(options)
|
|
55
|
+
const groupManager = new GroupManager(storageAdapter)
|
|
56
|
+
|
|
57
|
+
const shared = {
|
|
58
|
+
storageAdapter,
|
|
59
|
+
groupManager,
|
|
60
|
+
memoryBuffer,
|
|
61
|
+
pluginModeManager,
|
|
62
|
+
storage,
|
|
63
|
+
_initialized: false,
|
|
64
|
+
}
|
|
65
|
+
cache.set(storage, shared)
|
|
66
|
+
|
|
67
|
+
await storageAdapter.init()
|
|
68
|
+
await groupManager.ensureDefaultGroup()
|
|
69
|
+
await pluginModeManager.init(storage)
|
|
70
|
+
shared._initialized = true
|
|
71
|
+
|
|
72
|
+
console.log('[mockbubu] ✅ 共享初始化完成(storageAdapter / groupManager / pluginModeManager)')
|
|
73
|
+
return shared
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 同步等待初始化完成(供钩子 request 回调使用)
|
|
78
|
+
* @param {Object} options - Whistle 钩子 options
|
|
79
|
+
* @returns {Promise<Object>}
|
|
80
|
+
*/
|
|
81
|
+
async function awaitShared(options) {
|
|
82
|
+
const shared = await initShared(options)
|
|
83
|
+
while (!shared._initialized) {
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
85
|
+
}
|
|
86
|
+
return shared
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
initShared,
|
|
91
|
+
getShared,
|
|
92
|
+
awaitShared,
|
|
93
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件名: mock-response.js
|
|
3
|
+
* 功能: Mock 响应构造与条件匹配(共用模块)
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 返回 Mock 数据 (源数据或版本数据)
|
|
6
|
+
* - 条件匹配模式下匹配规则并返回对应数据
|
|
7
|
+
* - 处理版本切换
|
|
8
|
+
* - 处理响应头
|
|
9
|
+
*
|
|
10
|
+
* 从 lib/core/server-entry/response-handler.js 抽取,供 server / resWrite 钩子共用
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { MATCH_MODE_VALUE } = require('../../config/const')
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 返回 Mock 响应数据
|
|
17
|
+
* @param {Object} res - Whistle 响应对象
|
|
18
|
+
* @param {string} sourceData - 源数据 (file.json 的字符串内容)
|
|
19
|
+
* @param {Object} options - 配置选项
|
|
20
|
+
* @param {string} options.mockVersion - Mock 版本名称 (null 表示使用源数据)
|
|
21
|
+
* @param {Object} options.groupManager - 组管理器实例
|
|
22
|
+
* @param {string} options.currentGroupId - 当前组ID
|
|
23
|
+
* @param {string} options.filename - 文件名
|
|
24
|
+
* @param {Object} options.originalReq - Whistle 原始请求对象(用于条件匹配)
|
|
25
|
+
*/
|
|
26
|
+
async function sendMockResponse(res, sourceData, options = {}) {
|
|
27
|
+
const { mockVersion, groupManager, currentGroupId, filename, originalReq } = options
|
|
28
|
+
|
|
29
|
+
// 解析源数据 (storageAdapter.readFile 返回的是 session 对象的 JSON 字符串)
|
|
30
|
+
const session = JSON.parse(sourceData)
|
|
31
|
+
const sourceResponse = session?.res
|
|
32
|
+
const { statusCode, statusMessage } = sourceResponse
|
|
33
|
+
|
|
34
|
+
// 浅拷贝 headers,避免直接修改 session 对象(若未来引入缓存会产生副作用)
|
|
35
|
+
const headers = { ...sourceResponse.headers }
|
|
36
|
+
headers['from-mockbubu-source'] = 'true'
|
|
37
|
+
headers['x-mockbubu-mocked'] = 'true'
|
|
38
|
+
delete headers['content-encoding']
|
|
39
|
+
delete headers['content-length']
|
|
40
|
+
|
|
41
|
+
// 【条件匹配模式】
|
|
42
|
+
if (mockVersion === MATCH_MODE_VALUE) {
|
|
43
|
+
const matchResult = await matchByConditions({
|
|
44
|
+
groupManager,
|
|
45
|
+
currentGroupId,
|
|
46
|
+
filename,
|
|
47
|
+
originalReq,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
headers['x-mockbubu-match-mode'] = 'match'
|
|
51
|
+
|
|
52
|
+
if (matchResult.matched) {
|
|
53
|
+
// header 值必须为 ASCII,对中文规则名做 encodeURIComponent 编码
|
|
54
|
+
headers['x-mockbubu-match-rule'] = encodeURIComponent(matchResult.ruleName || '')
|
|
55
|
+
headers['x-mockbubu-match-params'] = encodeURIComponent(matchResult.paramsSummary || '')
|
|
56
|
+
res.writeHead(statusCode, statusMessage, headers)
|
|
57
|
+
res.end(JSON.stringify(matchResult.content ?? {}))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 全不命中 → 通知调用方透传请求,不返回 mock 数据
|
|
62
|
+
return { action: 'passthrough' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 【版本直选模式】先确认版本数据是否存在,再统一调用 writeHead
|
|
66
|
+
if (mockVersion && groupManager && currentGroupId && filename) {
|
|
67
|
+
const versionData = await groupManager.getGroupVersionContent(currentGroupId, filename, mockVersion)
|
|
68
|
+
if (versionData) {
|
|
69
|
+
res.writeHead(statusCode, statusMessage, headers)
|
|
70
|
+
res.end(JSON.stringify(versionData))
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
// 版本不存在时降级到源数据
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
res.writeHead(statusCode, statusMessage, headers)
|
|
77
|
+
res.end(sourceResponse.body)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 条件匹配核心逻辑
|
|
82
|
+
*
|
|
83
|
+
* 匹配规则:
|
|
84
|
+
* 1. 按条件数量降序(条件越多优先级越高)
|
|
85
|
+
* 2. 条件数量相同时按 priority 字段升序(数字越小越优先)
|
|
86
|
+
* 3. 空条件规则作为兜底,最后匹配
|
|
87
|
+
* 4. 全不命中时返回 matched: false,由调用方降级到 source
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} params
|
|
90
|
+
* @param {Object} params.groupManager - 组管理器
|
|
91
|
+
* @param {string} params.currentGroupId - 当前组ID
|
|
92
|
+
* @param {string} params.filename - 文件名(URL)
|
|
93
|
+
* @param {Object} params.originalReq - Whistle 原始请求对象
|
|
94
|
+
* @returns {{ matched: boolean, content?, ruleName?, paramsSummary? }}
|
|
95
|
+
*/
|
|
96
|
+
async function matchByConditions(params) {
|
|
97
|
+
const { groupManager, currentGroupId, filename, originalReq } = params
|
|
98
|
+
|
|
99
|
+
// 获取所有匹配规则(storage 层已按 priority 排序)
|
|
100
|
+
const matchVersions = await groupManager.getMatchVersions(currentGroupId, filename)
|
|
101
|
+
if (!matchVersions || matchVersions.length === 0) {
|
|
102
|
+
return { matched: false }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 提取当前请求参数
|
|
106
|
+
const queryParams = extractQueryParams(originalReq)
|
|
107
|
+
const payloadParams = extractPayloadParams(originalReq)
|
|
108
|
+
|
|
109
|
+
// 按条件数量降序 + priority 升序排序(条件越多越具体,优先匹配)
|
|
110
|
+
// storage 层已按 priority 排序,这里再按 conditions.length 降序叠加
|
|
111
|
+
const sorted = [...matchVersions].sort((a, b) => {
|
|
112
|
+
const condDiff = (b.conditions?.length || 0) - (a.conditions?.length || 0)
|
|
113
|
+
if (condDiff !== 0) return condDiff
|
|
114
|
+
return (a.priority || 0) - (b.priority || 0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
let fallbackRule = null
|
|
118
|
+
|
|
119
|
+
// 逐条匹配(AND 关系:所有条件必须同时满足)
|
|
120
|
+
for (const rule of sorted) {
|
|
121
|
+
if (!rule.conditions || rule.conditions.length === 0) {
|
|
122
|
+
// 空条件规则作为兜底,记录备用
|
|
123
|
+
if (!fallbackRule) fallbackRule = rule
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const allMatch = rule.conditions.every((cond) => {
|
|
128
|
+
const sourceParams = cond.source === 'query' ? queryParams : payloadParams
|
|
129
|
+
return matchCondition(sourceParams, cond)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
if (allMatch) {
|
|
133
|
+
return {
|
|
134
|
+
matched: true,
|
|
135
|
+
content: rule.content ?? {},
|
|
136
|
+
ruleName: rule.name,
|
|
137
|
+
paramsSummary: rule.conditions
|
|
138
|
+
.map(c => `${c.source}.${c.key}[${c.operator || 'eq'}]${c.value ?? ''}`)
|
|
139
|
+
.join(', '),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 尝试兜底规则(空条件规则)
|
|
145
|
+
if (fallbackRule) {
|
|
146
|
+
return {
|
|
147
|
+
matched: true,
|
|
148
|
+
content: fallbackRule.content ?? {},
|
|
149
|
+
ruleName: fallbackRule.name,
|
|
150
|
+
paramsSummary: '(default)',
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { matched: false }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 单条件匹配:根据 operator 执行对应的比较
|
|
159
|
+
* operator 缺省时向后兼容旧数据(等同 eq)
|
|
160
|
+
*
|
|
161
|
+
* @param {Object} sourceParams - 请求参数 key-value 对
|
|
162
|
+
* @param {{ key: string, operator?: string, value?: string }} cond
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
function matchCondition(sourceParams, cond) {
|
|
166
|
+
const { key, value = '' } = cond
|
|
167
|
+
const operator = cond.operator || 'eq'
|
|
168
|
+
|
|
169
|
+
// 条件不完整时跳过(视为满足),避免空值产生意外的匹配行为
|
|
170
|
+
if (!key) return true
|
|
171
|
+
const VALUELESS_OPS = new Set(['exists', 'not_exists'])
|
|
172
|
+
if (!VALUELESS_OPS.has(operator) && !value) return true
|
|
173
|
+
|
|
174
|
+
const exists = Object.prototype.hasOwnProperty.call(sourceParams, key)
|
|
175
|
+
const actual = exists ? String(sourceParams[key]) : undefined
|
|
176
|
+
const expected = String(value)
|
|
177
|
+
|
|
178
|
+
switch (operator) {
|
|
179
|
+
case 'eq':
|
|
180
|
+
return actual === expected
|
|
181
|
+
case 'neq':
|
|
182
|
+
return actual !== expected
|
|
183
|
+
case 'contains':
|
|
184
|
+
return actual !== undefined && actual.includes(expected)
|
|
185
|
+
case 'regex': {
|
|
186
|
+
if (!actual) return false
|
|
187
|
+
try {
|
|
188
|
+
return new RegExp(expected).test(actual)
|
|
189
|
+
} catch {
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
case 'exists':
|
|
194
|
+
return exists
|
|
195
|
+
case 'not_exists':
|
|
196
|
+
return !exists
|
|
197
|
+
default:
|
|
198
|
+
return actual === expected
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 从原始请求中提取 query 参数
|
|
204
|
+
* 来源:req.originalReq.url,fallback 到 x-whistle-full-url header
|
|
205
|
+
*
|
|
206
|
+
* @param {Object} originalReq - Whistle 原始请求对象
|
|
207
|
+
* @returns {Object} query 参数 key-value 对
|
|
208
|
+
*/
|
|
209
|
+
function extractQueryParams(originalReq) {
|
|
210
|
+
if (!originalReq) return {}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
// 某些 Whistle 代理场景(tunnel/HTTP2)下 originalReq.url 可能不含 query,
|
|
214
|
+
// 完整 URL 会在 x-whistle-full-url header 里(URL 编码格式)
|
|
215
|
+
const fullUrlHeader = originalReq.headers?.['x-whistle-full-url']
|
|
216
|
+
const rawUrl = originalReq.url ||
|
|
217
|
+
(fullUrlHeader ? decodeURIComponent(fullUrlHeader) : '') ||
|
|
218
|
+
''
|
|
219
|
+
const url = new URL(rawUrl)
|
|
220
|
+
const params = {}
|
|
221
|
+
url.searchParams.forEach((value, key) => {
|
|
222
|
+
params[key] = value
|
|
223
|
+
})
|
|
224
|
+
return params
|
|
225
|
+
} catch {
|
|
226
|
+
return {}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 从原始请求中提取 payload 参数(支持 JSON 和 x-www-form-urlencoded,只提取第一层 key-value)
|
|
232
|
+
* 来源:req.originalReq.body
|
|
233
|
+
*
|
|
234
|
+
* @param {Object} originalReq - Whistle 原始请求对象
|
|
235
|
+
* @returns {Object} payload 参数 key-value 对(值统一转为字符串)
|
|
236
|
+
*/
|
|
237
|
+
function extractPayloadParams(originalReq) {
|
|
238
|
+
if (!originalReq) return {}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
// 优先使用 mock 路径预缓冲的请求体,fallback 到 Whistle 预解析的 body
|
|
242
|
+
let body = originalReq._reqBody || originalReq.body || ''
|
|
243
|
+
|
|
244
|
+
// Buffer 转字符串
|
|
245
|
+
if (Buffer.isBuffer(body)) {
|
|
246
|
+
body = body.toString('utf8')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// body 可能已被 Whistle 预解析为对象,直接提取
|
|
250
|
+
if (typeof body === 'object' && body !== null) {
|
|
251
|
+
return extractFlatParams(body)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (typeof body !== 'string' || !body.trim()) {
|
|
255
|
+
return {}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 尝试 JSON 格式
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(body)
|
|
261
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
262
|
+
return extractFlatParams(parsed)
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
// 不是 JSON,继续尝试
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 尝试 application/x-www-form-urlencoded 格式
|
|
269
|
+
if (body.includes('=')) {
|
|
270
|
+
const result = {}
|
|
271
|
+
new URLSearchParams(body).forEach((value, key) => {
|
|
272
|
+
result[key] = value
|
|
273
|
+
})
|
|
274
|
+
if (Object.keys(result).length > 0) return result
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {}
|
|
278
|
+
} catch {
|
|
279
|
+
return {}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 从对象中提取第一层 string/number/boolean 类型的 key-value(统一转为字符串)
|
|
285
|
+
* @param {Object} obj
|
|
286
|
+
* @returns {Object}
|
|
287
|
+
*/
|
|
288
|
+
function extractFlatParams(obj) {
|
|
289
|
+
const params = {}
|
|
290
|
+
Object.keys(obj).forEach((key) => {
|
|
291
|
+
const val = obj[key]
|
|
292
|
+
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
|
293
|
+
params[key] = String(val)
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
return params
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
sendMockResponse,
|
|
301
|
+
matchByConditions,
|
|
302
|
+
matchCondition,
|
|
303
|
+
extractQueryParams,
|
|
304
|
+
extractPayloadParams,
|
|
305
|
+
extractFlatParams,
|
|
306
|
+
}
|
package/lib/utils/utils.js
CHANGED
|
@@ -83,7 +83,7 @@ const getGroupFileList = async (storage, groupId) => {
|
|
|
83
83
|
allFiles.forEach((item) => {
|
|
84
84
|
try {
|
|
85
85
|
// StorageAdapter 返回的文件名格式: groupId/filename
|
|
86
|
-
// 例如: default/https://
|
|
86
|
+
// 例如: default/https://a.com/us/api/...
|
|
87
87
|
// 不需要任何解码,直接使用
|
|
88
88
|
const name = item.name
|
|
89
89
|
|