@jonathanleelx/meddle 0.5.0-beta.2 → 0.5.0-beta.21

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.
Files changed (189) hide show
  1. package/CONFIG_STRUCTURE.md +8 -2
  2. package/README.md +2 -0
  3. package/bin/commands/mock/add.js +33 -4
  4. package/bin/commands/mock/delete.js +8 -1
  5. package/bin/commands/mock/index.js +8 -4
  6. package/bin/commands/mock/list.js +1 -0
  7. package/bin/commands/mock/toggle.js +8 -1
  8. package/bin/commands/mock/update.js +31 -5
  9. package/bin/commands/route/active.js +6 -2
  10. package/bin/commands/route/add.js +12 -2
  11. package/bin/commands/route/create.js +8 -1
  12. package/bin/commands/route/delete.js +12 -2
  13. package/bin/commands/route/update.js +12 -2
  14. package/bin/commands/update.js +4 -0
  15. package/bin/lib/file-access.js +68 -25
  16. package/bin/lib/output.js +20 -1
  17. package/bin/lib/proxy-detect.js +99 -20
  18. package/bin/lib/update-check.js +67 -6
  19. package/dist/core/config-diagnostics.d.ts.map +1 -1
  20. package/dist/core/config-diagnostics.js +6 -1
  21. package/dist/core/config-diagnostics.js.map +1 -1
  22. package/dist/core/custom-plugin-loader.d.ts.map +1 -1
  23. package/dist/core/custom-plugin-loader.js +2 -0
  24. package/dist/core/custom-plugin-loader.js.map +1 -1
  25. package/dist/core/h2-pool.d.ts +1 -0
  26. package/dist/core/h2-pool.d.ts.map +1 -1
  27. package/dist/core/h2-pool.js +79 -23
  28. package/dist/core/h2-pool.js.map +1 -1
  29. package/dist/core/mock-handler.d.ts +65 -0
  30. package/dist/core/mock-handler.d.ts.map +1 -1
  31. package/dist/core/mock-handler.js +525 -29
  32. package/dist/core/mock-handler.js.map +1 -1
  33. package/dist/core/mock-utils.d.ts +58 -0
  34. package/dist/core/mock-utils.d.ts.map +1 -0
  35. package/dist/core/mock-utils.js +167 -0
  36. package/dist/core/mock-utils.js.map +1 -0
  37. package/dist/core/plugin-bootstrap-runner.d.ts +1 -0
  38. package/dist/core/plugin-bootstrap-runner.d.ts.map +1 -1
  39. package/dist/core/plugin-bootstrap-runner.js +37 -1
  40. package/dist/core/plugin-bootstrap-runner.js.map +1 -1
  41. package/dist/core/plugin-intercept.d.ts.map +1 -1
  42. package/dist/core/plugin-intercept.js +1 -0
  43. package/dist/core/plugin-intercept.js.map +1 -1
  44. package/dist/core/plugin-source.d.ts +24 -0
  45. package/dist/core/plugin-source.d.ts.map +1 -0
  46. package/dist/core/plugin-source.js +47 -0
  47. package/dist/core/plugin-source.js.map +1 -0
  48. package/dist/core/types.d.ts +13 -1
  49. package/dist/core/types.d.ts.map +1 -1
  50. package/dist/helpers.d.ts +8 -1
  51. package/dist/helpers.d.ts.map +1 -1
  52. package/dist/helpers.js +22 -5
  53. package/dist/helpers.js.map +1 -1
  54. package/dist/plugins/builtin/mock-plugin.d.ts.map +1 -1
  55. package/dist/plugins/builtin/mock-plugin.js +6 -5
  56. package/dist/plugins/builtin/mock-plugin.js.map +1 -1
  57. package/dist/server/agent/tools.d.ts.map +1 -1
  58. package/dist/server/agent/tools.js +4 -0
  59. package/dist/server/agent/tools.js.map +1 -1
  60. package/dist/server/app-info.d.ts +19 -0
  61. package/dist/server/app-info.d.ts.map +1 -0
  62. package/dist/server/app-info.js +84 -0
  63. package/dist/server/app-info.js.map +1 -0
  64. package/dist/server/config.d.ts +1 -0
  65. package/dist/server/config.d.ts.map +1 -1
  66. package/dist/server/config.js +57 -7
  67. package/dist/server/config.js.map +1 -1
  68. package/dist/server/health.d.ts.map +1 -1
  69. package/dist/server/health.js +8 -0
  70. package/dist/server/health.js.map +1 -1
  71. package/dist/server/index.d.ts +15 -0
  72. package/dist/server/index.d.ts.map +1 -1
  73. package/dist/server/index.js +2 -0
  74. package/dist/server/index.js.map +1 -1
  75. package/dist/server/mocks.d.ts +26 -0
  76. package/dist/server/mocks.d.ts.map +1 -1
  77. package/dist/server/mocks.js +110 -13
  78. package/dist/server/mocks.js.map +1 -1
  79. package/dist/server/plugins.d.ts.map +1 -1
  80. package/dist/server/plugins.js +56 -11
  81. package/dist/server/plugins.js.map +1 -1
  82. package/dist/server/rule-files.d.ts +53 -0
  83. package/dist/server/rule-files.d.ts.map +1 -1
  84. package/dist/server/rule-files.js +281 -30
  85. package/dist/server/rule-files.js.map +1 -1
  86. package/index.js +33 -5
  87. package/mcp-server.js +3 -0
  88. package/package.json +3 -2
  89. package/plugins/builtin/mock-plugin.ts +9 -5
  90. package/web/dist/assets/alert-BmsRCcSL.js +1 -0
  91. package/web/dist/assets/arrow-right-BNum_060.js +1 -0
  92. package/web/dist/assets/{body-diff-view-D7dCWajG.js → body-diff-view-ByCHr9bv.js} +2 -2
  93. package/web/dist/assets/button-BFNlGZ5p.js +1 -0
  94. package/web/dist/assets/check-BkfHQ6re.js +1 -0
  95. package/web/dist/assets/checkbox-v2kzp7K8.js +1 -0
  96. package/web/dist/assets/chevron-down-DHwUtL4O.js +1 -0
  97. package/web/dist/assets/{circle-alert-Q8cZk9Rv.js → circle-alert-zMagrFuI.js} +1 -1
  98. package/web/dist/assets/{circle-x-C9USji_R.js → circle-x-BEkinhez.js} +1 -1
  99. package/web/dist/assets/code-DD-AYxxu.js +1 -0
  100. package/web/dist/assets/{copy-BYjq1hdN.js → copy-BRnscYy1.js} +1 -1
  101. package/web/dist/assets/detail-panel-DK7pM43g.js +4 -0
  102. package/web/dist/assets/{dist-D5Dj17P8.js → dist-BCvvYbmI.js} +1 -1
  103. package/web/dist/assets/dist-BNcjMpca.js +1 -0
  104. package/web/dist/assets/{dist-Bb9TeEub.js → dist-C-E2R2jk.js} +1 -1
  105. package/web/dist/assets/{dist-C6eaYCUo.js → dist-CFZ4xMSF.js} +1 -1
  106. package/web/dist/assets/{dist-C5nMErq8.js → dist-CRmX_NvH.js} +1 -1
  107. package/web/dist/assets/dist-DD7AvbXP.js +1 -0
  108. package/web/dist/assets/{dist-Cn0y0IRn.js → dist-LiAcI8CK.js} +1 -1
  109. package/web/dist/assets/dist-UcOFuoN0.js +5 -0
  110. package/web/dist/assets/{dist-B8XUKgXe.js → dist-YM3jbhT-.js} +1 -1
  111. package/web/dist/assets/editor-pane-actions-DN-pxFsl.js +1 -0
  112. package/web/dist/assets/es2015-CRmLuA2o.js +41 -0
  113. package/web/dist/assets/{global-panel-shell-R-EZQRhs.js → global-panel-shell-D32I98dR.js} +4 -4
  114. package/web/dist/assets/health-panel-Dw7qdFVS.js +1 -0
  115. package/web/dist/assets/index-CYUNdxqh.css +2 -0
  116. package/web/dist/assets/index-CkoYwomI.js +18 -0
  117. package/web/dist/assets/mobile-proxy-panel-DtiDkpAa.js +8 -0
  118. package/web/dist/assets/mock-config-C3jSpNNu.js +1 -0
  119. package/web/dist/assets/mock-editor-panel-ByCYWJjO.js +20 -0
  120. package/web/dist/assets/mocks-file-missing-note-DmrrCBYY.js +1 -0
  121. package/web/dist/assets/monaco-editor-6oLiu4W6.js +11 -0
  122. package/web/dist/assets/pane-split-DYB05onX.js +1 -0
  123. package/web/dist/assets/panel-heading-BS8loc49.js +1 -0
  124. package/web/dist/assets/{pencil-line-Cqe41E4h.js → pencil-line-CEZ7D-de.js} +1 -1
  125. package/web/dist/assets/plugin-code-editor-DGzCBEpg.js +2 -0
  126. package/web/dist/assets/plugin-config-NVw234EF.js +2 -0
  127. package/web/dist/assets/plugin-generator-Ba4hP5rg.js +2 -0
  128. package/web/dist/assets/plugin-test-dialog-B4ojc2_9.js +7 -0
  129. package/web/dist/assets/plus-ixZQRprm.js +1 -0
  130. package/web/dist/assets/{refresh-cw-CG3HaiQT.js → refresh-cw-BfLoHqg5.js} +1 -1
  131. package/web/dist/assets/rotate-ccw-CRguJya4.js +1 -0
  132. package/web/dist/assets/route-preview-BspvfNdW.js +1 -0
  133. package/web/dist/assets/{rule-ai-assistant-panel-CNL1O0GY.js → rule-ai-assistant-panel-CQTsZMJu.js} +2 -2
  134. package/web/dist/assets/rule-config-DDOeMRXa.js +15 -0
  135. package/web/dist/assets/rule-overview-panel-CmWdMs4I.js +1 -0
  136. package/web/dist/assets/{server-Cpj4KNwN.js → server-eYXq3PbE.js} +1 -1
  137. package/web/dist/assets/settings-panel-aB0ojA9D.js +1 -0
  138. package/web/dist/assets/{shield-check-OqUmVLQt.js → shield-check-hQXjdfZ7.js} +1 -1
  139. package/web/dist/assets/smartphone-D4Q7cQfm.js +1 -0
  140. package/web/dist/assets/switch-DpLnRvOJ.js +1 -0
  141. package/web/dist/assets/{syntax-highlight-BHsSDUha.js → syntax-highlight-EJCxGikd.js} +1 -1
  142. package/web/dist/assets/table-2-DvGHhMm4.js +1 -0
  143. package/web/dist/assets/table-C3mgEjkP.js +1 -0
  144. package/web/dist/assets/textarea-BjWF0pk4.js +1 -0
  145. package/web/dist/assets/{wand-sparkles-wkul5vje.js → wand-sparkles-DDGjzjnX.js} +1 -1
  146. package/web/dist/assets/{wifi-D9M35Ajj.js → wifi-QkTcTrED.js} +1 -1
  147. package/web/dist/assets/{zap-Djo4cgux.js → zap-CEVoVkAg.js} +1 -1
  148. package/web/dist/index.html +17 -12
  149. package/web/dist/assets/alert-Bve02Ufr.js +0 -1
  150. package/web/dist/assets/arrow-right-B4Mrlj2-.js +0 -1
  151. package/web/dist/assets/button-BTToMaBW.js +0 -1
  152. package/web/dist/assets/checkbox-DK9f9Mxu.js +0 -1
  153. package/web/dist/assets/chevron-down-CPngAx4M.js +0 -1
  154. package/web/dist/assets/chevron-right-V1DGIDbL.js +0 -1
  155. package/web/dist/assets/code-5_eJ_XpJ.js +0 -1
  156. package/web/dist/assets/code-fixer-IRTVs-a6.js +0 -20
  157. package/web/dist/assets/detail-panel--2r_Fgpi.js +0 -4
  158. package/web/dist/assets/dist-CxdY_A67.js +0 -5
  159. package/web/dist/assets/es2015-CfXu88Y7.js +0 -41
  160. package/web/dist/assets/file-system-access-DK7FI6pN.js +0 -1
  161. package/web/dist/assets/health-panel-CKt2-Gkj.js +0 -1
  162. package/web/dist/assets/index-DhzkcDnh.js +0 -18
  163. package/web/dist/assets/index-PcoCj3ST.css +0 -2
  164. package/web/dist/assets/mobile-proxy-panel-Dxz-7iYx.js +0 -8
  165. package/web/dist/assets/mock-config-84LSNP6M.js +0 -1
  166. package/web/dist/assets/mock-editor-panel-RJ1abekr.js +0 -1
  167. package/web/dist/assets/monaco-editor-CbL970eS.js +0 -11
  168. package/web/dist/assets/plugin-code-editor-DfLc4JdP.js +0 -2
  169. package/web/dist/assets/plugin-config-CT-u4XYw.js +0 -1
  170. package/web/dist/assets/plugin-generator-CyBK9FuM.js +0 -2
  171. package/web/dist/assets/plugin-test-dialog-B-NF0sGH.js +0 -7
  172. package/web/dist/assets/plus-tVxSydJ8.js +0 -1
  173. package/web/dist/assets/rotate-ccw-CAYM6Vv0.js +0 -1
  174. package/web/dist/assets/route-preview-C8nW9w7i.js +0 -1
  175. package/web/dist/assets/rule-config-DXfovZnm.js +0 -15
  176. package/web/dist/assets/select-DQ6s7CL8.js +0 -1
  177. package/web/dist/assets/settings-panel-BhXgMr_a.js +0 -1
  178. package/web/dist/assets/smartphone-DKeDuJTU.js +0 -1
  179. package/web/dist/assets/switch-C-Xk56TK.js +0 -1
  180. package/web/dist/assets/table-DymgLlcc.js +0 -1
  181. package/web/dist/assets/textarea-vuxJwU0x.js +0 -1
  182. /package/web/dist/assets/{ai-config-store-DQXVJVn_.js → ai-config-store-B_VpGvwx.js} +0 -0
  183. /package/web/dist/assets/{babel-DsprIpXZ.js → babel-D1quDdNt.js} +0 -0
  184. /package/web/dist/assets/{clipboard-Dz6MgwV-.js → clipboard-DUgC_tNv.js} +0 -0
  185. /package/web/dist/assets/{dist-DZJ9SMlJ.js → dist-Bq2klZ3g.js} +0 -0
  186. /package/web/dist/assets/{estree-BP7k0NIt.js → estree--Vq99DGn.js} +0 -0
  187. /package/web/dist/assets/{html-6FIWbqxX.js → html-D2PZBPnP.js} +0 -0
  188. /package/web/dist/assets/{postcss-Dnf30fQU.js → postcss-DfIslWug.js} +0 -0
  189. /package/web/dist/assets/{standalone-CjxtLKw-.js → standalone-D-fjaAIs.js} +0 -0
@@ -36,15 +36,27 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.MocksFileInvalidError = void 0;
39
40
  exports.dedupeMockRulesById = dedupeMockRulesById;
41
+ exports.formatMocksParseError = formatMocksParseError;
42
+ exports.parseMocksFileText = parseMocksFileText;
43
+ exports.findJsonErrorOffset = findJsonErrorOffset;
44
+ exports.resolveMocksWriteTarget = resolveMocksWriteTarget;
45
+ exports.resolveConfiguredMocksPath = resolveConfiguredMocksPath;
46
+ exports.detectLegacyMocksPath = detectLegacyMocksPath;
47
+ exports.formatLegacyMocksNotice = formatLegacyMocksNotice;
48
+ exports.readConfiguredMocksPath = readConfiguredMocksPath;
40
49
  exports.createMockHandler = createMockHandler;
41
50
  const fs = __importStar(require("fs"));
42
51
  const path = __importStar(require("path"));
52
+ const os = __importStar(require("os"));
53
+ const chokidar_1 = __importDefault(require("chokidar"));
43
54
  const debug_1 = __importDefault(require("debug"));
44
55
  const map_local_1 = require("./map-local");
45
56
  const proxy_record_1 = require("./proxy-record");
46
57
  const client_identity_1 = require("./client-identity");
47
58
  const body_utils_1 = require("./body-utils");
59
+ const mock_utils_1 = require("./mock-utils");
48
60
  const proxyDebug = (0, debug_1.default)('proxy');
49
61
  /** 按 id 去重,后出现的条目覆盖先前的(与前端 use-mocks 行为一致) */
50
62
  function dedupeMockRulesById(rules) {
@@ -56,13 +68,256 @@ function dedupeMockRulesById(rules) {
56
68
  }
57
69
  return Array.from(byId.values()).sort((a, b) => a.id - b.id);
58
70
  }
71
+ /** Thrown when a write would overwrite a mocks file that failed to parse (issue #120). */
72
+ class MocksFileInvalidError extends Error {
73
+ constructor(filePath, parseError) {
74
+ super(formatMocksParseError(filePath, parseError));
75
+ this.filePath = filePath;
76
+ this.parseError = parseError;
77
+ this.code = 'MOCKS_FILE_INVALID';
78
+ this.name = 'MocksFileInvalidError';
79
+ }
80
+ }
81
+ exports.MocksFileInvalidError = MocksFileInvalidError;
82
+ function formatMocksParseError(filePath, err) {
83
+ const loc = err.line != null ? `第 ${err.line} 行第 ${err.column} 列:` : '';
84
+ return `Mock 文件格式错误,已禁止写入以免覆盖: ${filePath} ${loc}${err.message}`;
85
+ }
86
+ /**
87
+ * Parse mocks file text. Issue #120: empty / whitespace-only text is an empty
88
+ * rule list (not an error); anything else that fails JSON.parse returns a
89
+ * parse error with 1-based line/column.
90
+ */
91
+ function parseMocksFileText(text) {
92
+ if (text.replace(/^\uFEFF/, '').trim() === '')
93
+ return { data: {}, error: null };
94
+ try {
95
+ return { data: JSON.parse(text.replace(/^\uFEFF/, '')), error: null };
96
+ }
97
+ catch (e) {
98
+ const message = String(e?.message || e);
99
+ let line = null;
100
+ let column = null;
101
+ const body = text.replace(/^\uFEFF/, '');
102
+ const lc = /\(line (\d+) column (\d+)\)/.exec(message);
103
+ const pos = /at position (\d+)/.exec(message);
104
+ const offset = lc ? null : pos ? Number(pos[1]) : findJsonErrorOffset(body);
105
+ if (lc) {
106
+ line = Number(lc[1]);
107
+ column = Number(lc[2]);
108
+ }
109
+ else {
110
+ const before = body.slice(0, offset ?? body.length).split('\n');
111
+ line = before.length;
112
+ column = before[before.length - 1].length + 1;
113
+ }
114
+ // Drop V8's quoted source snippet and the "(line x column y)" suffix; position is reported separately.
115
+ const short = message.replace(/\s*\(line \d+ column \d+\)/, '').split(/,\s*(?:\.\.\.)?"/s)[0].replace(/\s+in JSON at position \d+/, '').trim();
116
+ return { data: null, error: { line, column, message: short } };
117
+ }
118
+ }
119
+ /**
120
+ * Offset of the first JSON syntax error (V8 does not always report a
121
+ * position, e.g. "Unexpected token ']', ... is not valid JSON").
122
+ */
123
+ function findJsonErrorOffset(t) {
124
+ let i = 0;
125
+ const ws = () => { while (i < t.length && ' \t\n\r'.includes(t[i]))
126
+ i++; };
127
+ const fail = () => { throw i; };
128
+ const str = () => {
129
+ i++;
130
+ while (i < t.length && t[i] !== '"') {
131
+ if (t[i] === '\\')
132
+ i++;
133
+ else if (t.charCodeAt(i) < 0x20)
134
+ fail();
135
+ i++;
136
+ }
137
+ if (i >= t.length)
138
+ fail();
139
+ i++;
140
+ };
141
+ const value = () => {
142
+ ws();
143
+ const c = t[i];
144
+ if (c === '{') {
145
+ i++;
146
+ ws();
147
+ if (t[i] === '}') {
148
+ i++;
149
+ return;
150
+ }
151
+ for (;;) {
152
+ ws();
153
+ if (t[i] !== '"')
154
+ fail();
155
+ str();
156
+ ws();
157
+ if (t[i] !== ':')
158
+ fail();
159
+ i++;
160
+ value();
161
+ ws();
162
+ if (t[i] === ',') {
163
+ i++;
164
+ continue;
165
+ }
166
+ if (t[i] === '}') {
167
+ i++;
168
+ return;
169
+ }
170
+ fail();
171
+ }
172
+ }
173
+ else if (c === '[') {
174
+ i++;
175
+ ws();
176
+ if (t[i] === ']') {
177
+ i++;
178
+ return;
179
+ }
180
+ for (;;) {
181
+ value();
182
+ ws();
183
+ if (t[i] === ',') {
184
+ i++;
185
+ continue;
186
+ }
187
+ if (t[i] === ']') {
188
+ i++;
189
+ return;
190
+ }
191
+ fail();
192
+ }
193
+ }
194
+ else if (c === '"')
195
+ str();
196
+ else if (/[-0-9]/.test(c || '')) {
197
+ const m = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(t.slice(i));
198
+ if (!m)
199
+ fail();
200
+ i += m[0].length;
201
+ }
202
+ else {
203
+ for (const w of ['true', 'false', 'null'])
204
+ if (t.startsWith(w, i)) {
205
+ i += w.length;
206
+ return;
207
+ }
208
+ fail();
209
+ }
210
+ };
211
+ try {
212
+ value();
213
+ ws();
214
+ return i < t.length ? i : t.length;
215
+ }
216
+ catch (at) {
217
+ return typeof at === 'number' ? Math.min(at, t.length) : t.length;
218
+ }
219
+ }
220
+ /**
221
+ * Issue #123: the real file a write to `file` must replace. Symlinks are
222
+ * followed so the link itself is never replaced. A dangling link (target
223
+ * missing) creates the target at the link destination when its directory
224
+ * exists, otherwise throws a clear error (the link is left untouched).
225
+ */
226
+ function resolveMocksWriteTarget(file) {
227
+ try {
228
+ return fs.realpathSync(file);
229
+ }
230
+ catch (_) { /* missing or dangling */ }
231
+ let cur = path.resolve(file);
232
+ for (let hops = 0; hops < 40; hops++) {
233
+ let st;
234
+ try {
235
+ st = fs.lstatSync(cur);
236
+ }
237
+ catch (_) {
238
+ break;
239
+ }
240
+ if (!st.isSymbolicLink())
241
+ break;
242
+ cur = path.resolve(path.dirname(cur), fs.readlinkSync(cur));
243
+ }
244
+ if (cur !== path.resolve(file)) {
245
+ const parent = path.dirname(cur);
246
+ let parentReal;
247
+ try {
248
+ parentReal = fs.realpathSync(parent);
249
+ }
250
+ catch (_) {
251
+ throw new Error(`Mock 文件是指向不存在位置的符号链接,无法写入: ${file} -> ${cur}(目录 ${parent} 不存在)`);
252
+ }
253
+ return path.join(parentReal, path.basename(cur));
254
+ }
255
+ return cur;
256
+ }
257
+ /**
258
+ * Issue #121: turn the configured `mocksFilePath` into an absolute path.
259
+ * `~` / `~/…` expand to the user's home directory; a relative path is
260
+ * resolved against the meddle directory (MEDDLE_HOME), not the directory the
261
+ * proxy happened to be started from.
262
+ */
263
+ function resolveConfiguredMocksPath(raw, meddleDir, homeDir = os.homedir()) {
264
+ const v = typeof raw === 'string' ? raw.trim() : '';
265
+ if (!v)
266
+ return null;
267
+ let p = v;
268
+ if (p === '~')
269
+ p = homeDir;
270
+ else if (p.startsWith('~/') || p.startsWith('~\\'))
271
+ p = path.join(homeDir, p.slice(2));
272
+ return path.resolve(meddleDir, p);
273
+ }
274
+ /**
275
+ * Issue #121 migration notice: a relative `mocksFilePath` used to resolve
276
+ * against the proxy's start directory. If the file exists there but not
277
+ * under the new rule (MEDDLE_HOME), report both paths so the user can switch
278
+ * to the old absolute path. Never migrates or rewrites settings.
279
+ */
280
+ function detectLegacyMocksPath(raw, meddleDir, cwd = process.cwd(), homeDir = os.homedir()) {
281
+ const v = typeof raw === 'string' ? raw.trim() : '';
282
+ if (!v || path.isAbsolute(v) || v === '~' || v.startsWith('~/') || v.startsWith('~\\'))
283
+ return null;
284
+ const newPath = resolveConfiguredMocksPath(v, meddleDir, homeDir);
285
+ const oldPath = path.resolve(cwd, v);
286
+ if (!newPath || oldPath === newPath)
287
+ return null;
288
+ const isFile = (p) => { try {
289
+ return fs.statSync(p).isFile();
290
+ }
291
+ catch (_) {
292
+ return false;
293
+ } };
294
+ if (!isFile(oldPath) || isFile(newPath))
295
+ return null;
296
+ return { configured: v, oldPath, newPath };
297
+ }
298
+ function formatLegacyMocksNotice(l) {
299
+ return `Mock 文件路径「${l.configured}」现在相对于 Meddle 配置目录解析为 ${l.newPath}(不存在);` +
300
+ `按旧规则(相对于启动目录)找到的文件是 ${l.oldPath}。如需继续使用它,请把路径改为该绝对路径。`;
301
+ }
302
+ /** Read the raw (unresolved) mocksFilePath from settings.json. */
303
+ function readConfiguredMocksPath(settingsPath) {
304
+ try {
305
+ if (!fs.existsSync(settingsPath))
306
+ return null;
307
+ const v = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).mocksFilePath;
308
+ return typeof v === 'string' ? v : null;
309
+ }
310
+ catch (_) {
311
+ return null;
312
+ }
313
+ }
59
314
  function createMockHandler(ctx) {
60
315
  const DEFAULT_MOCK_FILE = path.join(ctx.meddleDir, 'mocks.json');
61
316
  function loadCustomPathsFromSettings() {
62
317
  try {
63
318
  if (fs.existsSync(ctx.settingsPath)) {
64
319
  const settings = JSON.parse(fs.readFileSync(ctx.settingsPath, 'utf8'));
65
- return { mocksFilePath: settings.mocksFilePath || null };
320
+ return { mocksFilePath: resolveConfiguredMocksPath(settings.mocksFilePath, ctx.meddleDir) };
66
321
  }
67
322
  }
68
323
  catch (error) {
@@ -70,45 +325,165 @@ function createMockHandler(ctx) {
70
325
  }
71
326
  return { mocksFilePath: null };
72
327
  }
328
+ /**
329
+ * Effective mocks file. Issue #118: a configured `mocksFilePath` is honoured
330
+ * even when the file does not exist yet (same as the CLI file mode) — the
331
+ * proxy starts with an empty rule list and the file is created on the first
332
+ * save. Previously a missing custom file silently fell back to the default
333
+ * file, so "switching" to a new file kept reading/writing the old one.
334
+ */
73
335
  function getMockFilePath() {
74
336
  if (ctx.currentMocksPath)
75
337
  return ctx.currentMocksPath;
76
338
  const customPaths = loadCustomPathsFromSettings();
77
- if (customPaths.mocksFilePath && fs.existsSync(customPaths.mocksFilePath)) {
78
- ctx.currentMocksPath = customPaths.mocksFilePath;
79
- return ctx.currentMocksPath;
339
+ ctx.currentMocksPath = customPaths.mocksFilePath || DEFAULT_MOCK_FILE;
340
+ return ctx.currentMocksPath;
341
+ }
342
+ /** Path whose contents are currently in ctx.mockRules (null before first load). */
343
+ let loadedMocksPath = null;
344
+ /** Issue #120: parse failure of the loaded file; while set, every write is refused. */
345
+ let parseError = null;
346
+ function getMocksParseError() { return parseError; }
347
+ /**
348
+ * Issue #123 (CI flake root cause): chokidar does not report writes that
349
+ * land while it is still setting up (ignoreInitial). Remember what was
350
+ * loaded so the watcher can re-check once it is ready.
351
+ */
352
+ let loadedSig = null;
353
+ function fileSig(file) {
354
+ try {
355
+ const st = fs.statSync(file);
356
+ return `${st.ino}:${st.size}:${st.mtimeMs}`;
357
+ }
358
+ catch (_) {
359
+ return 'missing';
80
360
  }
81
- ctx.currentMocksPath = DEFAULT_MOCK_FILE;
82
- return DEFAULT_MOCK_FILE;
83
361
  }
84
362
  function loadMockRules() {
363
+ const mockFile = getMockFilePath();
364
+ // Issue #118: switching to another file must not inherit the old
365
+ // file's id counter or rules; re-reading the same file keeps the
366
+ // counter monotonic (issue #115).
367
+ const switched = loadedMocksPath !== null && loadedMocksPath !== mockFile;
368
+ const firstLoadOfThisFile = loadedMocksPath !== mockFile;
369
+ if (switched) {
370
+ proxyDebug(`Mock 文件切换: ${loadedMocksPath} -> ${mockFile}`);
371
+ ctx.mockIdSeq = 1;
372
+ }
373
+ loadedMocksPath = mockFile;
374
+ if (watcherState && watcherState.file !== mockFile)
375
+ startWatcher(mockFile);
85
376
  try {
86
- const mockFile = getMockFilePath();
87
377
  if (fs.existsSync(mockFile)) {
88
- const data = JSON.parse(fs.readFileSync(mockFile, 'utf8'));
378
+ loadedSig = fileSig(mockFile);
379
+ const parsed = parseMocksFileText(fs.readFileSync(mockFile, 'utf8'));
380
+ if (parsed.error) {
381
+ // Issue #120: never treat a broken file as empty-and-writable.
382
+ const wasSame = parseError && parseError.message === parsed.error.message && parseError.line === parsed.error.line;
383
+ parseError = parsed.error;
384
+ ctx.mockRules = [];
385
+ if (!wasSame)
386
+ console.error(formatMocksParseError(mockFile, parsed.error));
387
+ return;
388
+ }
389
+ if (parseError)
390
+ console.log(`Mock 文件已修复,恢复正常: ${mockFile}`);
391
+ parseError = null;
392
+ const data = parsed.data || {};
89
393
  const loaded = Array.isArray(data.rules) ? data.rules : [];
90
- ctx.mockRules = dedupeMockRulesById(loaded);
91
- if (loaded.length !== ctx.mockRules.length) {
92
- proxyDebug(`Mock 规则去重: ${loaded.length} -> ${ctx.mockRules.length}`);
394
+ // Issue #115: never drop rules that share an id — give the
395
+ // later duplicates fresh ids instead (no silent data loss).
396
+ const { rules, reassigned } = (0, mock_utils_1.ensureUniqueMockRuleIds)(loaded);
397
+ ctx.mockRules = rules;
398
+ if (reassigned > 0) {
399
+ console.warn(`Mock 规则 id 重复或缺失,已为 ${reassigned} 条规则重新分配 id (${mockFile})`);
93
400
  }
94
- ctx.mockIdSeq = (data.nextId || Math.max(0, ...ctx.mockRules.map(r => r.id || 0))) + 1;
401
+ // nextId is the next id to hand out; a stale/missing value must
402
+ // never collide with ids already on disk (issue #115).
403
+ ctx.mockIdSeq = (0, mock_utils_1.nextMockRuleId)(ctx.mockRules, Math.max(Number(data.nextId) || 0, (Number(data.lastId) || 0) + 1, ctx.mockIdSeq || 0));
95
404
  proxyDebug(`已加载 ${ctx.mockRules.length} 条 Mock 规则 (${mockFile})`);
96
405
  }
406
+ else {
407
+ // File removed / never created — clear in-memory rules (watcher
408
+ // unlink, or a configured path that does not exist yet). The
409
+ // file is created on the first save; nothing else is touched.
410
+ ctx.mockRules = [];
411
+ parseError = null;
412
+ loadedSig = 'missing';
413
+ if (firstLoadOfThisFile && mockFile !== DEFAULT_MOCK_FILE) {
414
+ console.warn(`Mock 规则文件不存在,将以空列表开始,首次保存时创建: ${mockFile}`);
415
+ }
416
+ proxyDebug(`Mock 规则文件不存在,已清空内存规则 (${mockFile})`);
417
+ }
97
418
  }
98
419
  catch (err) {
99
420
  console.error('加载 mock 规则失败:', err.message);
100
421
  ctx.mockRules = [];
101
422
  }
102
423
  }
424
+ function assertMocksWritable() {
425
+ const mockFile = getMockFilePath();
426
+ // Re-validate what is on disk right before writing: the file may have
427
+ // been broken after the last load (watcher not fired yet, stale tab).
428
+ if (!parseError && fs.existsSync(mockFile)) {
429
+ try {
430
+ const parsed = parseMocksFileText(fs.readFileSync(mockFile, 'utf8'));
431
+ if (parsed.error) {
432
+ parseError = parsed.error;
433
+ console.error(formatMocksParseError(mockFile, parsed.error));
434
+ }
435
+ }
436
+ catch (_) { /* unreadable: let the write report it */ }
437
+ }
438
+ if (parseError)
439
+ throw new MocksFileInvalidError(mockFile, parseError);
440
+ }
103
441
  function saveMockRules() {
442
+ // Issue #120: single choke point for every write — refuse to overwrite a broken file.
443
+ assertMocksWritable();
104
444
  try {
105
- ctx.mockRules = dedupeMockRulesById(ctx.mockRules);
445
+ // Issue #115: keep every rule (re-id duplicates instead of dropping
446
+ // them) and persist a sequence that is above every stored id.
447
+ ctx.mockRules = (0, mock_utils_1.ensureUniqueMockRuleIds)(ctx.mockRules).rules;
448
+ ctx.mockIdSeq = (0, mock_utils_1.nextMockRuleId)(ctx.mockRules, ctx.mockIdSeq);
106
449
  const mockFile = getMockFilePath();
107
- fs.writeFileSync(mockFile, JSON.stringify({ nextId: ctx.mockIdSeq, rules: ctx.mockRules }, null, 2), 'utf8');
450
+ const dir = path.dirname(mockFile);
451
+ if (!fs.existsSync(dir))
452
+ fs.mkdirSync(dir, { recursive: true });
453
+ // Issue #123: write next to the real file so a symlinked mocks file
454
+ // (e.g. into a dotfiles repo) stays a symlink, and keep its mode/owner.
455
+ const target = resolveMocksWriteTarget(mockFile);
456
+ let existing = null;
457
+ try {
458
+ existing = fs.statSync(target);
459
+ }
460
+ catch (_) { /* new file */ }
461
+ // Write to a temp file then rename, so a crash never leaves half a file (issue #120).
462
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
463
+ fs.writeFileSync(tmp, JSON.stringify({ nextId: ctx.mockIdSeq, rules: ctx.mockRules }, null, 2), { encoding: 'utf8', mode: existing ? existing.mode & 0o7777 : 0o666 });
464
+ try {
465
+ if (existing) {
466
+ fs.chmodSync(tmp, existing.mode & 0o7777);
467
+ try {
468
+ fs.chownSync(tmp, existing.uid, existing.gid);
469
+ }
470
+ catch (_) { /* not permitted: best effort */ }
471
+ }
472
+ fs.renameSync(tmp, target);
473
+ loadedSig = fileSig(mockFile);
474
+ }
475
+ catch (e) {
476
+ try {
477
+ fs.unlinkSync(tmp);
478
+ }
479
+ catch (_) { /* ignore */ }
480
+ throw e;
481
+ }
108
482
  proxyDebug(`Mock 规则已保存到 ${mockFile}`);
109
483
  }
110
484
  catch (err) {
111
485
  console.error('保存 mock 规则失败:', err.message);
486
+ throw err;
112
487
  }
113
488
  }
114
489
  function matchMockRule(url, method) {
@@ -117,25 +492,23 @@ function createMockHandler(ctx) {
117
492
  return false;
118
493
  if (rule.method && rule.method !== '*' && rule.method.toUpperCase() !== method.toUpperCase())
119
494
  return false;
120
- try {
121
- return new RegExp(rule.urlPattern).test(url);
122
- }
123
- catch {
124
- return url.includes(rule.urlPattern);
125
- }
495
+ if (!(0, mock_utils_1.matchUrlPattern)(url, rule.urlPattern))
496
+ return false;
497
+ if (!(0, mock_utils_1.matchQueryCondition)(url, rule.query))
498
+ return false;
499
+ return true;
126
500
  }) || null;
127
501
  }
128
- function buildMockResponseForTest(rule) {
502
+ function buildMockResponseForTest(rule, requestHeaders) {
129
503
  const statusCode = rule.statusCode || 200;
130
504
  const ruleHeaders = {};
131
505
  for (const [key, value] of Object.entries(rule.headers || {})) {
132
506
  ruleHeaders[key.toLowerCase()] = value;
133
507
  }
134
- const headers = {
508
+ const headers = (0, mock_utils_1.resolveHeaderPlaceholders)((0, mock_utils_1.mergeResponseHeaders)({
135
509
  'x-mock-rule': encodeURIComponent(rule.name || rule.id?.toString() || rule.urlPattern || ''),
136
- 'content-type': ruleHeaders['content-type'] || 'application/json',
137
- ...ruleHeaders,
138
- };
510
+ 'content-type': 'application/json',
511
+ }, ruleHeaders), requestHeaders);
139
512
  let body = '';
140
513
  if (rule.bodyType === 'file' && rule.body) {
141
514
  let filePath = rule.body.replace(/^file:\/\//, '').replace(/^\/[A-Za-z]:\//, '');
@@ -171,13 +544,12 @@ function createMockHandler(ctx) {
171
544
  for (const [key, value] of Object.entries(rule.headers || {})) {
172
545
  ruleHeaders[key.toLowerCase()] = value;
173
546
  }
174
- const responseHeaders = {
547
+ const responseHeaders = (0, mock_utils_1.resolveHeaderPlaceholders)((0, mock_utils_1.mergeResponseHeaders)({
175
548
  'X-Mock-Rule': encodeURIComponent(mockRuleName),
176
549
  'Access-Control-Allow-Origin': '*',
177
550
  'Access-Control-Allow-Methods': '*',
178
551
  'Access-Control-Allow-Headers': '*',
179
- ...ruleHeaders
180
- };
552
+ }, ruleHeaders), req.headers);
181
553
  let finalStatusCode = statusCode;
182
554
  let statusMessage = 'OK (Mock)';
183
555
  if (isFileBody) {
@@ -338,9 +710,133 @@ function createMockHandler(ctx) {
338
710
  doSend();
339
711
  }
340
712
  }
713
+ /** Active file watcher (issue #118: follows the effective mocks file). */
714
+ let watcherState = null;
715
+ let watcherOnReload;
716
+ function startWatcher(mockFile) {
717
+ if (watcherState) {
718
+ watcherState.close();
719
+ proxyDebug(`stopped watching mocks file: ${watcherState.file}`);
720
+ }
721
+ const dir = path.dirname(mockFile);
722
+ const base = path.basename(mockFile);
723
+ try {
724
+ if (!fs.existsSync(dir))
725
+ fs.mkdirSync(dir, { recursive: true });
726
+ }
727
+ catch (_) { /* ignore */ }
728
+ const debugWatch = process.env.MEDDLE_DEBUG_WATCH === '1';
729
+ const t0 = Date.now();
730
+ const dbg = (msg) => { if (debugWatch)
731
+ console.error(`[watch +${Date.now() - t0}ms] ${msg}`); };
732
+ // Issue #123: when the mocks path is a symlink, edits land on the real
733
+ // target (git pull in a dotfiles repo, fixing JSON there), so watch the
734
+ // target's directory too and accept events for either path.
735
+ const resolveTarget = () => {
736
+ try {
737
+ return resolveMocksWriteTarget(mockFile);
738
+ }
739
+ catch (_) {
740
+ return path.resolve(mockFile);
741
+ }
742
+ };
743
+ let target = resolveTarget();
744
+ let targetDir = null;
745
+ // Watch the directory (not only the file) so atomic replace / recreate
746
+ // still triggers — same pattern as route-rules watcher.
747
+ const initialTargetDir = path.dirname(target);
748
+ targetDir = path.resolve(initialTargetDir) !== path.resolve(dir) && fs.existsSync(initialTargetDir) ? initialTargetDir : null;
749
+ // Both directories go into the initial watch so they are ready together.
750
+ const watcher = chokidar_1.default.watch(targetDir ? [dir, targetDir] : dir, {
751
+ ignoreInitial: true,
752
+ depth: 0,
753
+ // Follow the symlink target explicitly (below) instead of relying on
754
+ // chokidar's implicit following, which differs per platform backend.
755
+ followSymlinks: false,
756
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
757
+ });
758
+ // Catch up on a write chokidar did not report because it landed while a
759
+ // directory watch was still being set up (ignoreInitial).
760
+ const catchUp = (why) => {
761
+ if (closed || !watcherState || watcherState.file !== mockFile)
762
+ return;
763
+ if (fileSig(mockFile) !== loadedSig)
764
+ reload(why);
765
+ };
766
+ const watchTargetDir = () => {
767
+ const want = path.dirname(target);
768
+ const next = path.resolve(want) === path.resolve(dir) ? null : want;
769
+ if (next === targetDir)
770
+ return;
771
+ if (targetDir)
772
+ watcher.unwatch(targetDir);
773
+ targetDir = next;
774
+ if (targetDir && fs.existsSync(targetDir)) {
775
+ watcher.add(targetDir);
776
+ // chokidar emits no 'ready' for add(); re-check a few times
777
+ // while the new directory watch comes up.
778
+ for (const ms of [100, 300, 1000]) {
779
+ setTimeout(() => catchUp('changed while target watch was starting'), ms).unref?.();
780
+ }
781
+ }
782
+ dbg(`target ${target}; extra dir ${targetDir ?? '(none)'}`);
783
+ };
784
+ watchTargetDir();
785
+ let closed = false;
786
+ const reload = (event, changedPath) => {
787
+ // Ignore late events from a watcher that was replaced, and any
788
+ // file in the directory other than the active mocks file / its target.
789
+ if (closed || !watcherState || watcherState.file !== mockFile)
790
+ return;
791
+ if (changedPath) {
792
+ const p = path.resolve(changedPath);
793
+ if (p !== path.resolve(mockFile) && p !== target)
794
+ return;
795
+ }
796
+ // The link itself may have been retargeted: follow the new target.
797
+ const nextTarget = resolveTarget();
798
+ if (nextTarget !== target) {
799
+ target = nextTarget;
800
+ watchTargetDir();
801
+ }
802
+ proxyDebug(`mocks file ${event}: ${base}`);
803
+ loadMockRules();
804
+ if (watcherOnReload)
805
+ watcherOnReload();
806
+ };
807
+ if (debugWatch) {
808
+ watcher.on('all', (ev, p) => dbg(`${ev} ${p}`));
809
+ }
810
+ watcher.on('ready', () => {
811
+ dbg('ready');
812
+ catchUp('changed before watcher ready');
813
+ });
814
+ watcher.on('change', (p) => reload('changed', p));
815
+ watcher.on('add', (p) => reload('added', p));
816
+ watcher.on('unlink', (p) => reload('removed', p));
817
+ watcherState = {
818
+ file: mockFile,
819
+ close: () => {
820
+ closed = true;
821
+ watcher.close().catch(() => { });
822
+ },
823
+ };
824
+ proxyDebug(`watching mocks file: ${mockFile}`);
825
+ }
826
+ /**
827
+ * Watch the mocks file for external edits (CLI file-mode / hand edits).
828
+ * Aligns with route-rules watcher so file-only writes still hot-reload (issue #85).
829
+ * When the effective mocks file changes (issue #118), loadMockRules moves
830
+ * the watcher to the new file automatically.
831
+ */
832
+ function initMockFileWatcher(onReload) {
833
+ watcherOnReload = onReload;
834
+ startWatcher(getMockFilePath());
835
+ }
341
836
  return {
342
- getMockFilePath, loadMockRules, saveMockRules, matchMockRule,
837
+ getMockFilePath, loadMockRules, saveMockRules, assertMocksWritable, getMocksParseError, matchMockRule,
343
838
  buildMockResponseForTest, sendMockResponse, loadCustomPathsFromSettings,
839
+ initMockFileWatcher,
344
840
  };
345
841
  }
346
842
  //# sourceMappingURL=mock-handler.js.map