whistle.mockbubu 1.0.0-dev.4 → 2.0.0-beta.0

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/lib/server.js CHANGED
@@ -1,63 +1,180 @@
1
1
  const {
2
- getProperty,
3
- setProperty,
4
2
  getFilename,
5
3
  getRule,
6
4
  isJsonReq,
7
5
  setApiListUpdated,
8
- getVersionContent,
9
6
  handleBuffer2String,
10
7
  withTryCatch,
11
8
  } = require('./utils')
12
- const qs = require('qs')
9
+ const GroupManager = require('./group-manager')
10
+ const StorageAdapter = require('./storage-adapter')
13
11
 
14
12
  module.exports = (server, { storage }) => {
15
- server.on('request', (req, res) => {
13
+ console.log('[mockbubu] 🚀 server.js 模块已加载!')
14
+
15
+ // 使用 StorageAdapter 包装 Whistle storage
16
+ const actualBaseDir = storage.baseDir || storage._options?.baseDir
17
+ const storageAdapter = new StorageAdapter({ baseDir: actualBaseDir })
18
+
19
+ // 初始化组管理器(使用适配器)
20
+ const groupManager = new GroupManager(storageAdapter)
21
+
22
+ // ✅ 修复 Issue #3: 添加捕获中标志,防止并发重复捕获
23
+ // Key: `${groupId}/${filename}`, Value: Promise (捕获完成时 resolve)
24
+ const capturingFiles = new Map()
25
+
26
+ // 同步初始化:确保初始化完成后再处理请求
27
+ let isInitialized = false
28
+ storageAdapter.init().then(async () => {
29
+ console.log('[mockbubu] ✅ V3 Storage 初始化完成')
30
+ // 确保默认组存在
31
+ await groupManager.ensureDefaultGroup()
32
+ isInitialized = true
33
+ }).catch(err => {
34
+ console.error('[mockbubu] ❌ V3 Storage 初始化失败:', err)
35
+ isInitialized = true // 即使失败也标记为已初始化,避免阻塞
36
+ })
37
+
38
+ server.on('request', async (req, res) => {
39
+ // 等待初始化完成
40
+ // eslint-disable-next-line no-unmodified-loop-condition
41
+ while (!isInitialized) {
42
+ await new Promise(resolve => setTimeout(resolve, 100))
43
+ }
16
44
  const { originalReq, method } = req
17
45
  const { headers, ruleValue, url, pattern } = originalReq
18
46
  const filename = getFilename(originalReq)
19
47
  const rule = getRule(originalReq)
20
48
 
49
+ // 调试日志:记录所有请求
50
+ if (url.includes('trend_card')) {
51
+ console.log('[mockbubu] 🔍 [trend_card] 收到请求')
52
+ console.log(`[mockbubu] 🔍 [trend_card] URL: ${url}`)
53
+ console.log(`[mockbubu] 🔍 [trend_card] Accept: ${headers.accept}`)
54
+ console.log(`[mockbubu] 🔍 [trend_card] Sec-Fetch-Dest: ${headers['sec-fetch-dest'] || headers['Sec-Fetch-Dest']}`)
55
+ console.log('[mockbubu] 🔍 [trend_card] 完整 Headers:', JSON.stringify(headers, null, 2))
56
+ console.log(`[mockbubu] 🔍 [trend_card] isJsonReq 判断结果: ${isJsonReq(headers)}`)
57
+ }
58
+
21
59
  // 非json请求直接透传
22
- if (!isJsonReq(headers)) return req.passThrough()
23
-
24
- // 获取请求信息
25
- let str = ''
26
- req.on('data', (chunk) => {
27
- str += chunk
28
- })
29
- req.on('end', () => {
30
- setProperty(storage, filename, { payload: str })
31
- })
32
- setProperty(storage, filename, {
33
- query: qs.parse(new URL(url).searchParams.toString()),
34
- })
60
+ if (!isJsonReq(headers)) {
61
+ console.log(`[mockbubu] ⏭️ 非 JSON 请求已跳过 | File: ${filename}`)
62
+ console.log(`[mockbubu] 📋 Headers: Accept=${headers.accept}, Sec-Fetch-Dest=${headers['sec-fetch-dest'] || headers['Sec-Fetch-Dest']}`)
63
+ return req.passThrough()
64
+ }
65
+
66
+ console.log(`[mockbubu] 🎯 JSON 请求,开始捕获 | URL: ${url}`)
67
+
68
+ // V3 架构:不再写入 query/payload properties(无用数据)
35
69
 
36
70
  try {
37
- const { mock, mockVersion } = getProperty(storage, filename) || {}
38
- const sessionCache = storage.readFile(filename)
71
+ // 获取当前激活的组ID
72
+ const currentGroupId = await groupManager.getCurrentGroupId()
73
+ // 读取该组的配置
74
+ const groupConfig = await groupManager.getGroupFileConfig(currentGroupId, filename)
75
+ const { mock, mockVersion } = groupConfig
39
76
 
40
- if (mock) {
41
- if (sessionCache) {
42
- const resCache = JSON.parse(sessionCache)?.res
43
- const { statusCode, statusMessage, headers } = resCache
44
-
45
- headers['from-res-cache'] = 'true'
46
- delete headers['content-encoding']
47
- delete headers['content-length']
48
- res.writeHead(statusCode, statusMessage, headers)
49
- if (mockVersion) {
50
- const mockVersionContent = getVersionContent({ storage, filename, versionName: mockVersion })
77
+ // 从组目录读取缓存文件
78
+ const filePath = `${currentGroupId}/${filename}`
79
+ const sessionCache = await storageAdapter.readFile(filePath)
80
+
81
+ if (mock && sessionCache) {
82
+ // Mock: true 且有缓存,返回模拟数据
83
+ const resCache = JSON.parse(sessionCache)?.res
84
+ const { statusCode, statusMessage, headers } = resCache
85
+
86
+ headers['from-res-cache'] = 'true'
87
+ delete headers['content-encoding']
88
+ delete headers['content-length']
89
+ res.writeHead(statusCode, statusMessage, headers)
90
+
91
+ if (mockVersion) {
92
+ // 从当前组配置读取版本内容
93
+ const mockVersionContent = await groupManager.getGroupVersionContent(currentGroupId, filename, mockVersion)
94
+ if (mockVersionContent) {
51
95
  res.end(JSON.stringify(mockVersionContent))
52
96
  } else {
97
+ // 版本不存在时降级到原始数据
53
98
  res.end(resCache.body)
54
99
  }
100
+ } else {
101
+ res.end(resCache.body)
55
102
  }
56
- } else {
57
- // 已有缓存则直接透传
58
- if (sessionCache) return req.passThrough()
103
+ } else if (mock && !sessionCache) {
104
+ // Mock: true 但无缓存,需要先捕获数据
105
+ const captureKey = `${currentGroupId}/${filename}`
59
106
 
60
- // 命中插件规则,没有勾选mock的缓存最新的接口数据
107
+ // ✅ 修复:检查文件是否已存在,如果存在则直接透传,不重新捕获
108
+ // 设计原则:同一个URL只捕获一次,用户如需更新数据,应先删除旧文件
109
+ const configKey = groupManager.getGroupConfigKey(currentGroupId, filename)
110
+ const hasConfig = await storageAdapter.hasFileInIndex(configKey)
111
+
112
+ if (hasConfig) {
113
+ // 文件已存在,直接透传请求,不重新捕获
114
+ console.log(`[mockbubu] ⏭️ 文件已存在,跳过捕获并透传: ${filename}`)
115
+ return req.passThrough()
116
+ } else {
117
+ // 文件不存在 - 首次捕获
118
+ console.log(`[mockbubu] 🎣 首次捕获: ${filename}`)
119
+ }
120
+
121
+ // ✅ 修复 Issue #3: 检查是否正在捕获中
122
+ if (capturingFiles.has(captureKey)) {
123
+ // 正在捕获中,等待捕获完成
124
+ console.log(`[mockbubu] ⏳ 检测到并发请求,等待捕获完成: ${filename}`)
125
+
126
+ try {
127
+ // 等待第一个请求捕获完成(最多等待10秒)
128
+ await Promise.race([
129
+ capturingFiles.get(captureKey),
130
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error('捕获超时')), 10000)),
131
+ ])
132
+
133
+ // 捕获完成,重新读取缓存
134
+ const freshCache = await storageAdapter.readFile(filePath)
135
+ if (freshCache) {
136
+ console.log(`[mockbubu] ✅ 等待成功,返回捕获的数据: ${filename}`)
137
+ const resCache = JSON.parse(freshCache)?.res
138
+ const { statusCode, statusMessage = '', headers } = resCache
139
+
140
+ headers['from-res-cache'] = 'true'
141
+ delete headers['content-encoding']
142
+ delete headers['content-length']
143
+ res.writeHead(statusCode, statusMessage, headers)
144
+
145
+ // 检查是否需要使用版本数据
146
+ if (mockVersion) {
147
+ const mockVersionContent = await groupManager.getGroupVersionContent(currentGroupId, filename, mockVersion)
148
+ if (mockVersionContent) {
149
+ res.end(JSON.stringify(mockVersionContent))
150
+ } else {
151
+ res.end(resCache.body)
152
+ }
153
+ } else {
154
+ res.end(resCache.body)
155
+ }
156
+ return
157
+ } else {
158
+ // 等待后仍无缓存,降级到透传
159
+ console.warn(`[mockbubu] ⚠️ 等待后仍无缓存,降级透传: ${filename}`)
160
+ return req.passThrough()
161
+ }
162
+ } catch (err) {
163
+ // 等待超时或失败,降级到透传
164
+ console.error(`[mockbubu] ❌ 等待捕获失败,降级透传: ${filename}`, err.message)
165
+ return req.passThrough()
166
+ }
167
+ }
168
+
169
+ // ✅ 修复 Issue #3: 第一个请求,设置捕获标志
170
+ let resolveCapture
171
+ const capturePromise = new Promise(resolve => { resolveCapture = resolve })
172
+ capturingFiles.set(captureKey, capturePromise)
173
+
174
+ // 传递 hasConfig 标志到捕获逻辑中,避免重复检查
175
+ const isFirstCapture = !hasConfig
176
+
177
+ // 无缓存,捕获最新的接口数据
61
178
  const client = req.request((svrRes) => {
62
179
  const encoding = svrRes.headers['content-encoding']
63
180
  let body
@@ -66,34 +183,190 @@ module.exports = (server, { storage }) => {
66
183
  body = body ? Buffer.concat([body, data]) : data
67
184
  })
68
185
  svrRes.on('end', withTryCatch(async () => {
69
- if (!body) return
70
-
71
- const content = await handleBuffer2String({ body, encoding })
72
- // 获取完整的抓包数据,要等待响应完成
73
- req.getSession(async (session) => {
74
- // 如果设置了 enable://hide 会获取到空数据
75
- if (!session) {
76
- return
77
- }
78
- setProperty(storage, filename, {
79
- method,
80
- rule,
81
- status: session.res.statusCode,
82
- pattern,
83
- ruleValue: ruleValue || 'pathname',
84
- url,
85
- mock: false,
86
- locked: false,
87
- date: Date.now(),
88
- mockTime: null,
186
+ try {
187
+ if (!body) return
188
+
189
+ const content = await handleBuffer2String({ body, encoding })
190
+ // 获取完整的抓包数据,要等待响应完成
191
+ req.getSession(async (session) => {
192
+ try {
193
+ // 如果设置了 enable://hide 会获取到空数据
194
+ if (!session) {
195
+ return
196
+ }
197
+
198
+ // 完全隔离架构:将元数据和配置写入当前组
199
+ // ✅ 修复 Issue #4: 使用外层已检查的 isFirstCapture,避免重复检查导致逻辑不一致
200
+
201
+ if (isFirstCapture) {
202
+ // 首次捕获:创建完整配置(保持当前 mock 状态)
203
+ const configToSet = {
204
+ method,
205
+ rule,
206
+ status: session.res.statusCode,
207
+ pattern,
208
+ ruleValue: ruleValue || 'pathname',
209
+ url: filename, // ✅ 修复:使用 filename(不带查询参数),而不是完整的 url
210
+ date: Date.now(),
211
+ mock, // 使用当前的 mock 状态(true)
212
+ locked: false,
213
+ mockVersion: null,
214
+ mockTime: null,
215
+ }
216
+ await groupManager.setGroupFileConfig(currentGroupId, filename, configToSet)
217
+ } else {
218
+ // 已存在配置,仅更新元数据(不覆盖 mock、locked 等用户配置)
219
+ const existingGroupConfig = await groupManager.getGroupFileConfig(currentGroupId, filename)
220
+ await groupManager.setGroupFileConfig(currentGroupId, filename, {
221
+ ...existingGroupConfig,
222
+ method,
223
+ rule,
224
+ status: session.res.statusCode,
225
+ pattern,
226
+ ruleValue: ruleValue || 'pathname',
227
+ url: filename, // ✅ 修复:使用 filename(不带查询参数),而不是完整的 url
228
+ date: Date.now(),
229
+ })
230
+ }
231
+
232
+ // 将文件写入组目录
233
+ const tempSession = JSON.parse(JSON.stringify(session))
234
+ tempSession.res.body = content
235
+ const groupFilePath = `${currentGroupId}/${filename}`
236
+ await storageAdapter.writeFile(groupFilePath, JSON.stringify(tempSession))
237
+
238
+ await setApiListUpdated(storage, true)
239
+
240
+ console.log(`[mockbubu] ✅ 捕获完成: ${filename}`)
241
+ } finally {
242
+ // ✅ 修复 Issue #3: 捕获完成,清除标志并通知等待的请求
243
+ resolveCapture()
244
+ capturingFiles.delete(captureKey)
245
+ }
89
246
  })
247
+ } catch (err) {
248
+ console.error(`[mockbubu] ❌ 捕获过程出错: ${filename}`, err)
249
+ // 出错也要清除标志
250
+ resolveCapture()
251
+ capturingFiles.delete(captureKey)
252
+ }
253
+ }))
254
+
255
+ // 将响应透传给客户端
256
+ svrRes.pipe(res)
257
+ })
258
+
259
+ req.pipe(client)
260
+ } else {
261
+ // mock: false,已有缓存则直接透传,无缓存则捕获
262
+ if (sessionCache) {
263
+ return req.passThrough()
264
+ }
265
+
266
+ // ✅ 修复 Issue #3: mock=false 时也要避免重复捕获
267
+ const captureKey = `${currentGroupId}/${filename}`
268
+
269
+ // ✅ 修复:检查文件是否已存在,如果存在则直接透传,不重新捕获
270
+ // 设计原则:同一个URL只捕获一次,用户如需更新数据,应先删除旧文件
271
+ const configKey = groupManager.getGroupConfigKey(currentGroupId, filename)
272
+ const hasConfig = await storageAdapter.hasFileInIndex(configKey)
273
+
274
+ if (hasConfig) {
275
+ // 文件已存在,直接透传请求,不重新捕获
276
+ console.log(`[mockbubu] ⏭️ 文件已存在(mock=false),跳过捕获并透传: ${filename}`)
277
+ return req.passThrough()
278
+ } else {
279
+ // 文件不存在 - 首次捕获
280
+ console.log(`[mockbubu] 🎣 首次捕获(mock=false): ${filename}`)
281
+ }
90
282
 
91
- const tempSession = JSON.parse(JSON.stringify(session))
92
- tempSession.res.body = content
93
- storage.writeFile(filename, JSON.stringify(tempSession))
283
+ if (capturingFiles.has(captureKey)) {
284
+ // 正在捕获中,直接透传(mock=false 不需要等待)
285
+ console.log(`[mockbubu] ⏳ 检测到并发请求(mock=false),直接透传: ${filename}`)
286
+ return req.passThrough()
287
+ }
288
+
289
+ // ✅ 修复 Issue #3: 设置捕获标志
290
+ let resolveCapture
291
+ const capturePromise = new Promise(resolve => { resolveCapture = resolve })
292
+ capturingFiles.set(captureKey, capturePromise)
293
+
294
+ // 传递 hasConfig 标志到捕获逻辑中,避免重复检查
295
+ const isFirstCapture = !hasConfig
94
296
 
95
- setApiListUpdated(storage, true)
96
- })
297
+ // 无缓存,捕获最新的接口数据
298
+ const client = req.request((svrRes) => {
299
+ const encoding = svrRes.headers['content-encoding']
300
+ let body
301
+
302
+ svrRes.on('data', (data) => {
303
+ body = body ? Buffer.concat([body, data]) : data
304
+ })
305
+ svrRes.on('end', withTryCatch(async () => {
306
+ try {
307
+ if (!body) return
308
+
309
+ const content = await handleBuffer2String({ body, encoding })
310
+ // 获取完整的抓包数据,要等待响应完成
311
+ req.getSession(async (session) => {
312
+ try {
313
+ // 如果设置了 enable://hide 会获取到空数据
314
+ if (!session) {
315
+ return
316
+ }
317
+
318
+ // 完全隔离架构:将元数据和配置写入当前组
319
+ // ✅ 修复 Issue #4: 使用外层已检查的 isFirstCapture,避免重复检查导致逻辑不一致
320
+
321
+ if (isFirstCapture) {
322
+ // 首次捕获:创建完整配置
323
+ await groupManager.setGroupFileConfig(currentGroupId, filename, {
324
+ method,
325
+ rule,
326
+ status: session.res.statusCode,
327
+ pattern,
328
+ ruleValue: ruleValue || 'pathname',
329
+ url: filename, // ✅ 修复:使用 filename(不带查询参数),而不是完整的 url
330
+ date: Date.now(),
331
+ mock: false,
332
+ locked: false,
333
+ mockVersion: null,
334
+ mockTime: null,
335
+ })
336
+ } else {
337
+ // 已存在配置,仅更新元数据(不覆盖 mock、locked 等用户配置)
338
+ const existingGroupConfig = await groupManager.getGroupFileConfig(currentGroupId, filename)
339
+ await groupManager.setGroupFileConfig(currentGroupId, filename, {
340
+ ...existingGroupConfig,
341
+ method,
342
+ rule,
343
+ status: session.res.statusCode,
344
+ pattern,
345
+ ruleValue: ruleValue || 'pathname',
346
+ url: filename, // ✅ 修复:使用 filename(不带查询参数),而不是完整的 url
347
+ date: Date.now(),
348
+ })
349
+ }
350
+
351
+ // 将文件写入组目录
352
+ const tempSession = JSON.parse(JSON.stringify(session))
353
+ tempSession.res.body = content
354
+ const groupFilePath = `${currentGroupId}/${filename}`
355
+ await storageAdapter.writeFile(groupFilePath, JSON.stringify(tempSession))
356
+
357
+ await setApiListUpdated(storage, true)
358
+ } finally {
359
+ // ✅ 修复 Issue #3: 捕获完成,清除标志
360
+ resolveCapture()
361
+ capturingFiles.delete(captureKey)
362
+ }
363
+ })
364
+ } catch (err) {
365
+ console.error(`[mockbubu] ❌ 捕获过程出错: ${filename}`, err)
366
+ // 出错也要清除标志
367
+ resolveCapture()
368
+ capturingFiles.delete(captureKey)
369
+ }
97
370
  }))
98
371
 
99
372
  // 将响应透传给客户端