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.
@@ -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
+ }
@@ -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://m.shein.com/us/bff-api/...
86
+ // 例如: default/https://a.com/us/api/...
87
87
  // 不需要任何解码,直接使用
88
88
  const name = item.name
89
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whistle.mockbubu",
3
- "version": "2.2.7",
3
+ "version": "2.2.9-beta.1",
4
4
  "description": "mock response data",
5
5
  "scripts": {
6
6
  "lint": "eslint . --ext .js",