@jonathanleelx/meddle 0.5.0-beta.9 → 0.5.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/CONFIG_STRUCTURE.md +9 -2
- package/README.md +18 -0
- package/bin/commands/mock/add.js +59 -21
- package/bin/commands/mock/delete.js +16 -24
- package/bin/commands/mock/index.js +59 -25
- package/bin/commands/mock/list.js +35 -18
- package/bin/commands/mock/toggle.js +17 -27
- package/bin/commands/mock/update.js +39 -30
- package/bin/commands/route/active.js +20 -3
- package/bin/commands/route/add.js +113 -94
- package/bin/commands/route/create.js +8 -1
- package/bin/commands/route/delete.js +87 -53
- package/bin/commands/route/index.js +74 -26
- package/bin/commands/route/list.js +2 -2
- package/bin/commands/route/preview.js +2 -2
- package/bin/commands/route/show.js +12 -3
- package/bin/commands/route/update.js +135 -92
- package/bin/commands/status.js +27 -11
- package/bin/commands/update.js +4 -0
- package/bin/doctor.js +8 -4
- package/bin/index.js +11 -7
- package/bin/lib/api-client.js +14 -2
- package/bin/lib/file-access.js +234 -35
- package/bin/lib/meddle-home.js +14 -1
- package/bin/lib/output.js +30 -4
- package/bin/lib/proxy-detect.js +99 -20
- package/bin/lib/proxy-or-file.js +93 -0
- package/bin/lib/route-write-args.js +150 -0
- package/bin/lib/rule-file-edit.js +263 -0
- package/bin/lib/ruleset-readonly.js +61 -0
- package/bin/lib/update-check.js +67 -6
- package/dist/cert.d.ts +2 -0
- package/dist/cert.d.ts.map +1 -1
- package/dist/cert.js +53 -1
- package/dist/cert.js.map +1 -1
- package/dist/core/browser.d.ts +10 -1
- package/dist/core/browser.d.ts.map +1 -1
- package/dist/core/browser.js +252 -36
- package/dist/core/browser.js.map +1 -1
- package/dist/core/config-diagnostics.d.ts.map +1 -1
- package/dist/core/config-diagnostics.js +6 -1
- package/dist/core/config-diagnostics.js.map +1 -1
- package/dist/core/custom-plugin-loader.d.ts.map +1 -1
- package/dist/core/custom-plugin-loader.js +2 -0
- package/dist/core/custom-plugin-loader.js.map +1 -1
- package/dist/core/mock-handler.d.ts +63 -0
- package/dist/core/mock-handler.d.ts.map +1 -1
- package/dist/core/mock-handler.js +641 -29
- package/dist/core/mock-handler.js.map +1 -1
- package/dist/core/mock-utils.d.ts +58 -0
- package/dist/core/mock-utils.d.ts.map +1 -0
- package/dist/core/mock-utils.js +167 -0
- package/dist/core/mock-utils.js.map +1 -0
- package/dist/core/plugin-bootstrap-runner.d.ts +1 -0
- package/dist/core/plugin-bootstrap-runner.d.ts.map +1 -1
- package/dist/core/plugin-bootstrap-runner.js +37 -1
- package/dist/core/plugin-bootstrap-runner.js.map +1 -1
- package/dist/core/plugin-source.d.ts +24 -0
- package/dist/core/plugin-source.d.ts.map +1 -0
- package/dist/core/plugin-source.js +47 -0
- package/dist/core/plugin-source.js.map +1 -0
- package/dist/core/proxy-record.d.ts +14 -0
- package/dist/core/proxy-record.d.ts.map +1 -1
- package/dist/core/proxy-record.js +39 -0
- package/dist/core/proxy-record.js.map +1 -1
- package/dist/core/rule-file-edit.d.ts +81 -0
- package/dist/core/rule-file-edit.d.ts.map +1 -0
- package/dist/core/rule-file-edit.js +243 -0
- package/dist/core/rule-file-edit.js.map +1 -0
- package/dist/core/static-server.d.ts.map +1 -1
- package/dist/core/static-server.js +15 -13
- package/dist/core/static-server.js.map +1 -1
- package/dist/core/types.d.ts +15 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/plugins/builtin/mock-plugin.d.ts.map +1 -1
- package/dist/plugins/builtin/mock-plugin.js +6 -5
- package/dist/plugins/builtin/mock-plugin.js.map +1 -1
- package/dist/server/agent/tools.d.ts.map +1 -1
- package/dist/server/agent/tools.js +24 -18
- package/dist/server/agent/tools.js.map +1 -1
- package/dist/server/app-info.d.ts +33 -0
- package/dist/server/app-info.d.ts.map +1 -0
- package/dist/server/app-info.js +116 -0
- package/dist/server/app-info.js.map +1 -0
- package/dist/server/config.d.ts +1 -0
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +114 -16
- package/dist/server/config.js.map +1 -1
- package/dist/server/health.d.ts.map +1 -1
- package/dist/server/health.js +8 -0
- package/dist/server/health.js.map +1 -1
- package/dist/server/index.d.ts +16 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +10 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/json-parse-error.d.ts +7 -0
- package/dist/server/json-parse-error.d.ts.map +1 -0
- package/dist/server/json-parse-error.js +28 -0
- package/dist/server/json-parse-error.js.map +1 -0
- package/dist/server/mocks.d.ts +26 -0
- package/dist/server/mocks.d.ts.map +1 -1
- package/dist/server/mocks.js +110 -13
- package/dist/server/mocks.js.map +1 -1
- package/dist/server/plugins.d.ts.map +1 -1
- package/dist/server/plugins.js +56 -11
- package/dist/server/plugins.js.map +1 -1
- package/dist/server/rule-files.d.ts +36 -0
- package/dist/server/rule-files.d.ts.map +1 -1
- package/dist/server/rule-files.js +388 -107
- package/dist/server/rule-files.js.map +1 -1
- package/dist/server/ruleset-lock.d.ts +62 -0
- package/dist/server/ruleset-lock.d.ts.map +1 -0
- package/dist/server/ruleset-lock.js +234 -0
- package/dist/server/ruleset-lock.js.map +1 -0
- package/index.js +128 -16
- package/mcp-server.js +108 -93
- package/package.json +1 -1
- package/plugins/builtin/mock-plugin.ts +9 -5
- package/web/dist/assets/alert-B514Frte.js +1 -0
- package/web/dist/assets/arrow-right-BNum_060.js +1 -0
- package/web/dist/assets/{body-diff-view-B-4XWmL6.js → body-diff-view-SfLCLzs9.js} +2 -2
- package/web/dist/assets/button-BFNlGZ5p.js +1 -0
- package/web/dist/assets/check-BkfHQ6re.js +1 -0
- package/web/dist/assets/checkbox-CQyFsZzS.js +1 -0
- package/web/dist/assets/chevron-down-DHwUtL4O.js +1 -0
- package/web/dist/assets/{circle-alert-Q8cZk9Rv.js → circle-alert-zMagrFuI.js} +1 -1
- package/web/dist/assets/{circle-x-C9USji_R.js → circle-x-BEkinhez.js} +1 -1
- package/web/dist/assets/code-DD-AYxxu.js +1 -0
- package/web/dist/assets/{copy-BYjq1hdN.js → copy-BRnscYy1.js} +1 -1
- package/web/dist/assets/detail-panel-DZRKJG2W.js +4 -0
- package/web/dist/assets/dist-BQN0rzUS.js +1 -0
- package/web/dist/assets/{dist-CEd_wnRC.js → dist-C-E2R2jk.js} +1 -1
- package/web/dist/assets/{dist-C6eaYCUo.js → dist-CFZ4xMSF.js} +1 -1
- package/web/dist/assets/{dist-DWmDaXVN.js → dist-CRmX_NvH.js} +1 -1
- package/web/dist/assets/{dist-C8RHq5At.js → dist-DLQKRjuW.js} +1 -1
- package/web/dist/assets/{dist-Blyulj4_.js → dist-LiAcI8CK.js} +1 -1
- package/web/dist/assets/{dist-Ce4ujolc.js → dist-fzPL-8F9.js} +1 -1
- package/web/dist/assets/{dist-DoE7uybN.js → dist-wv9ebJK_.js} +1 -1
- package/web/dist/assets/editor-pane-actions-_ckwcCII.js +1 -0
- package/web/dist/assets/{es2015-DG46NGN7.js → es2015-DPJTBYa9.js} +1 -1
- package/web/dist/assets/global-panel-shell-Iir-c_pQ.js +6 -0
- package/web/dist/assets/health-panel-Dmf-EhJz.js +1 -0
- package/web/dist/assets/index-C4dIj1nT.js +21 -0
- package/web/dist/assets/index-CdYMl1M7.css +2 -0
- package/web/dist/assets/{mobile-proxy-panel-tU0NyJ3P.js → mobile-proxy-panel-CFcwa11A.js} +6 -6
- package/web/dist/assets/mock-config-CGl50MIr.js +1 -0
- package/web/dist/assets/mock-editor-panel-xcCk23oH.js +20 -0
- package/web/dist/assets/mocks-file-missing-note-CW_ONVLA.js +1 -0
- package/web/dist/assets/monaco-editor-BkP7envQ.js +11 -0
- package/web/dist/assets/pane-bar-5j9Nf7W_.js +1 -0
- package/web/dist/assets/pane-split-C0OSe365.js +1 -0
- package/web/dist/assets/panel-heading-izT5q0ZN.js +1 -0
- package/web/dist/assets/{pencil-line-Cqe41E4h.js → pencil-line-CEZ7D-de.js} +1 -1
- package/web/dist/assets/plugin-code-editor-DKE9dFEE.js +2 -0
- package/web/dist/assets/plugin-config-Cg09TsuV.js +2 -0
- package/web/dist/assets/plugin-generator-CT1f1tu0.js +2 -0
- package/web/dist/assets/plugin-test-dialog-BbLIMnkJ.js +7 -0
- package/web/dist/assets/plus-ixZQRprm.js +1 -0
- package/web/dist/assets/{refresh-cw-CG3HaiQT.js → refresh-cw-BfLoHqg5.js} +1 -1
- package/web/dist/assets/rotate-ccw-CRguJya4.js +1 -0
- package/web/dist/assets/route-preview-CKO7nF4W.js +1 -0
- package/web/dist/assets/{rule-ai-assistant-panel-DMh0F9dr.js → rule-ai-assistant-panel-CtfUqdYG.js} +2 -2
- package/web/dist/assets/rule-config-Cb4ejIXI.js +15 -0
- package/web/dist/assets/rule-overview-panel-xtHTS27O.js +1 -0
- package/web/dist/assets/{server-Cpj4KNwN.js → server-eYXq3PbE.js} +1 -1
- package/web/dist/assets/settings-panel-CpQuE6nr.js +1 -0
- package/web/dist/assets/{shield-check-OqUmVLQt.js → shield-check-hQXjdfZ7.js} +1 -1
- package/web/dist/assets/smartphone-D4Q7cQfm.js +1 -0
- package/web/dist/assets/switch-94vgsMHm.js +1 -0
- package/web/dist/assets/{syntax-highlight-Dk0-ewLI.js → syntax-highlight-BFgN_C0Y.js} +1 -1
- package/web/dist/assets/{table-2-BR5e3z8m.js → table-2-DvGHhMm4.js} +1 -1
- package/web/dist/assets/table-DaXD6jJv.js +1 -0
- package/web/dist/assets/textarea-9wB8Hsz-.js +1 -0
- package/web/dist/assets/toast-9yeTf7d9.js +1 -0
- package/web/dist/assets/use-mocks-file-status-BUIUJ4oQ.js +1 -0
- package/web/dist/assets/{wand-sparkles-CJ7fSnom.js → wand-sparkles-Bmt4dDOf.js} +1 -1
- package/web/dist/assets/{wifi-CCLKyySQ.js → wifi-Cjj_Br1u.js} +1 -1
- package/web/dist/assets/{zap-DR-xiUMe.js → zap-BuOtogAv.js} +1 -1
- package/web/dist/index.html +16 -10
- package/web/dist/assets/alert-DCvlZAiv.js +0 -1
- package/web/dist/assets/arrow-right-B4Mrlj2-.js +0 -1
- package/web/dist/assets/button-BTToMaBW.js +0 -1
- package/web/dist/assets/checkbox-D1Z9DtjF.js +0 -1
- package/web/dist/assets/chevron-down-CPngAx4M.js +0 -1
- package/web/dist/assets/chevron-right-V1DGIDbL.js +0 -1
- package/web/dist/assets/code-5_eJ_XpJ.js +0 -1
- package/web/dist/assets/code-fixer-D48sCDtp.js +0 -20
- package/web/dist/assets/detail-panel-BSFz9CUw.js +0 -4
- package/web/dist/assets/file-system-access-_og-Tg9o.js +0 -1
- package/web/dist/assets/global-panel-shell-5cJneeis.js +0 -6
- package/web/dist/assets/health-panel-BS3L2e5d.js +0 -1
- package/web/dist/assets/index-CvTR1IVi.js +0 -18
- package/web/dist/assets/index-bfV7sxx5.css +0 -2
- package/web/dist/assets/mock-config-CULJP6Vt.js +0 -1
- package/web/dist/assets/mock-editor-panel-9wlDaySA.js +0 -1
- package/web/dist/assets/monaco-editor-BttaUoOz.js +0 -11
- package/web/dist/assets/plugin-code-editor-BAIBp4lg.js +0 -2
- package/web/dist/assets/plugin-config-ButUV2SZ.js +0 -1
- package/web/dist/assets/plugin-generator-uceUPSCv.js +0 -2
- package/web/dist/assets/plugin-test-dialog-BrJ4HfRX.js +0 -7
- package/web/dist/assets/plus-tVxSydJ8.js +0 -1
- package/web/dist/assets/rotate-ccw-CAYM6Vv0.js +0 -1
- package/web/dist/assets/route-preview-C4cGrS1-.js +0 -1
- package/web/dist/assets/rule-config-BTTGNwMs.js +0 -15
- package/web/dist/assets/rule-overview-panel-SFR2OloL.js +0 -1
- package/web/dist/assets/select-aBXQ0zNa.js +0 -1
- package/web/dist/assets/settings-panel-BMReDG8b.js +0 -1
- package/web/dist/assets/smartphone-DKeDuJTU.js +0 -1
- package/web/dist/assets/switch-Bes_YiJu.js +0 -1
- package/web/dist/assets/table-nW7uQVcl.js +0 -1
- package/web/dist/assets/textarea-BSI0rnW6.js +0 -1
- /package/web/dist/assets/{babel-DCr4VA1c.js → babel-bwZm0qi3.js} +0 -0
- /package/web/dist/assets/{clipboard-VhPXNN5m.js → clipboard-Bt1qY01V.js} +0 -0
- /package/web/dist/assets/{estree-qO_m2Qaw.js → estree-MatbZDwq.js} +0 -0
- /package/web/dist/assets/{html-VDGahtyk.js → html-CCrsbFYX.js} +0 -0
- /package/web/dist/assets/{postcss-BUm8EKMw.js → postcss-BxN7Gr8n.js} +0 -0
- /package/web/dist/assets/{standalone-Bj8SSd8Q.js → standalone-DsnFnsqM.js} +0 -0
|
@@ -36,15 +36,28 @@ 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.MOCKS_MISSING_RULES_MESSAGE = exports.MocksFileInvalidError = void 0;
|
|
39
40
|
exports.dedupeMockRulesById = dedupeMockRulesById;
|
|
41
|
+
exports.formatMocksParseError = formatMocksParseError;
|
|
42
|
+
exports.mocksDocumentShapeError = mocksDocumentShapeError;
|
|
43
|
+
exports.parseMocksFileText = parseMocksFileText;
|
|
44
|
+
exports.findJsonErrorOffset = findJsonErrorOffset;
|
|
45
|
+
exports.resolveMocksWriteTarget = resolveMocksWriteTarget;
|
|
46
|
+
exports.resolveConfiguredMocksPath = resolveConfiguredMocksPath;
|
|
47
|
+
exports.detectLegacyMocksPath = detectLegacyMocksPath;
|
|
48
|
+
exports.formatLegacyMocksNotice = formatLegacyMocksNotice;
|
|
49
|
+
exports.readConfiguredMocksPath = readConfiguredMocksPath;
|
|
40
50
|
exports.createMockHandler = createMockHandler;
|
|
41
51
|
const fs = __importStar(require("fs"));
|
|
42
52
|
const path = __importStar(require("path"));
|
|
53
|
+
const os = __importStar(require("os"));
|
|
54
|
+
const chokidar_1 = __importDefault(require("chokidar"));
|
|
43
55
|
const debug_1 = __importDefault(require("debug"));
|
|
44
56
|
const map_local_1 = require("./map-local");
|
|
45
57
|
const proxy_record_1 = require("./proxy-record");
|
|
46
58
|
const client_identity_1 = require("./client-identity");
|
|
47
59
|
const body_utils_1 = require("./body-utils");
|
|
60
|
+
const mock_utils_1 = require("./mock-utils");
|
|
48
61
|
const proxyDebug = (0, debug_1.default)('proxy');
|
|
49
62
|
/** 按 id 去重,后出现的条目覆盖先前的(与前端 use-mocks 行为一致) */
|
|
50
63
|
function dedupeMockRulesById(rules) {
|
|
@@ -56,13 +69,274 @@ function dedupeMockRulesById(rules) {
|
|
|
56
69
|
}
|
|
57
70
|
return Array.from(byId.values()).sort((a, b) => a.id - b.id);
|
|
58
71
|
}
|
|
72
|
+
/** Thrown when a write would overwrite a mocks file that failed to parse (issue #120). */
|
|
73
|
+
class MocksFileInvalidError extends Error {
|
|
74
|
+
constructor(filePath, parseError) {
|
|
75
|
+
super(formatMocksParseError(filePath, parseError));
|
|
76
|
+
this.filePath = filePath;
|
|
77
|
+
this.parseError = parseError;
|
|
78
|
+
this.code = 'MOCKS_FILE_INVALID';
|
|
79
|
+
this.name = 'MocksFileInvalidError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
exports.MocksFileInvalidError = MocksFileInvalidError;
|
|
83
|
+
function formatMocksParseError(filePath, err) {
|
|
84
|
+
const loc = err.line != null ? `第 ${err.line} 行第 ${err.column} 列:` : '';
|
|
85
|
+
return `Mock 文件格式错误,已禁止写入以免覆盖: ${filePath} ${loc}${err.message}`;
|
|
86
|
+
}
|
|
87
|
+
/** Valid mocks.json is an object with a `rules` array. Empty / whitespace files skip this. */
|
|
88
|
+
exports.MOCKS_MISSING_RULES_MESSAGE = '缺少 rules 数组';
|
|
89
|
+
function mocksDocumentShapeError(data) {
|
|
90
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.rules)) {
|
|
91
|
+
return exports.MOCKS_MISSING_RULES_MESSAGE;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parse mocks file text. Issue #120: empty / whitespace-only text is an empty
|
|
97
|
+
* rule list (not an error); anything else that fails JSON.parse returns a
|
|
98
|
+
* parse error with 1-based line/column. Issue #160: JSON that parses but is
|
|
99
|
+
* not `{ rules: [...] }` is the same class of error (do not treat as []).
|
|
100
|
+
*/
|
|
101
|
+
function parseMocksFileText(text) {
|
|
102
|
+
if (text.replace(/^\uFEFF/, '').trim() === '')
|
|
103
|
+
return { data: {}, error: null };
|
|
104
|
+
try {
|
|
105
|
+
const data = JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
106
|
+
const shape = mocksDocumentShapeError(data);
|
|
107
|
+
if (shape)
|
|
108
|
+
return { data: null, error: { line: null, column: null, message: shape } };
|
|
109
|
+
return { data, error: null };
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
const message = String(e?.message || e);
|
|
113
|
+
let line = null;
|
|
114
|
+
let column = null;
|
|
115
|
+
const body = text.replace(/^\uFEFF/, '');
|
|
116
|
+
const lc = /\(line (\d+) column (\d+)\)/.exec(message);
|
|
117
|
+
const pos = /at position (\d+)/.exec(message);
|
|
118
|
+
const offset = lc ? null : pos ? Number(pos[1]) : findJsonErrorOffset(body);
|
|
119
|
+
if (lc) {
|
|
120
|
+
line = Number(lc[1]);
|
|
121
|
+
column = Number(lc[2]);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const before = body.slice(0, offset ?? body.length).split('\n');
|
|
125
|
+
line = before.length;
|
|
126
|
+
column = before[before.length - 1].length + 1;
|
|
127
|
+
}
|
|
128
|
+
// Drop V8's quoted source snippet and the "(line x column y)" suffix; position is reported separately.
|
|
129
|
+
const short = message.replace(/\s*\(line \d+ column \d+\)/, '').split(/,\s*(?:\.\.\.)?"/s)[0].replace(/\s+in JSON at position \d+/, '').trim();
|
|
130
|
+
return { data: null, error: { line, column, message: short } };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Offset of the first JSON syntax error (V8 does not always report a
|
|
135
|
+
* position, e.g. "Unexpected token ']', ... is not valid JSON").
|
|
136
|
+
*/
|
|
137
|
+
function findJsonErrorOffset(t) {
|
|
138
|
+
let i = 0;
|
|
139
|
+
const ws = () => { while (i < t.length && ' \t\n\r'.includes(t[i]))
|
|
140
|
+
i++; };
|
|
141
|
+
const fail = () => { throw i; };
|
|
142
|
+
const str = () => {
|
|
143
|
+
i++;
|
|
144
|
+
while (i < t.length && t[i] !== '"') {
|
|
145
|
+
if (t[i] === '\\')
|
|
146
|
+
i++;
|
|
147
|
+
else if (t.charCodeAt(i) < 0x20)
|
|
148
|
+
fail();
|
|
149
|
+
i++;
|
|
150
|
+
}
|
|
151
|
+
if (i >= t.length)
|
|
152
|
+
fail();
|
|
153
|
+
i++;
|
|
154
|
+
};
|
|
155
|
+
const value = () => {
|
|
156
|
+
ws();
|
|
157
|
+
const c = t[i];
|
|
158
|
+
if (c === '{') {
|
|
159
|
+
i++;
|
|
160
|
+
ws();
|
|
161
|
+
if (t[i] === '}') {
|
|
162
|
+
i++;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
for (;;) {
|
|
166
|
+
ws();
|
|
167
|
+
if (t[i] !== '"')
|
|
168
|
+
fail();
|
|
169
|
+
str();
|
|
170
|
+
ws();
|
|
171
|
+
if (t[i] !== ':')
|
|
172
|
+
fail();
|
|
173
|
+
i++;
|
|
174
|
+
value();
|
|
175
|
+
ws();
|
|
176
|
+
if (t[i] === ',') {
|
|
177
|
+
i++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (t[i] === '}') {
|
|
181
|
+
i++;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
fail();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else if (c === '[') {
|
|
188
|
+
i++;
|
|
189
|
+
ws();
|
|
190
|
+
if (t[i] === ']') {
|
|
191
|
+
i++;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (;;) {
|
|
195
|
+
value();
|
|
196
|
+
ws();
|
|
197
|
+
if (t[i] === ',') {
|
|
198
|
+
i++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (t[i] === ']') {
|
|
202
|
+
i++;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
fail();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else if (c === '"')
|
|
209
|
+
str();
|
|
210
|
+
else if (/[-0-9]/.test(c || '')) {
|
|
211
|
+
const m = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(t.slice(i));
|
|
212
|
+
if (!m)
|
|
213
|
+
fail();
|
|
214
|
+
i += m[0].length;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
for (const w of ['true', 'false', 'null'])
|
|
218
|
+
if (t.startsWith(w, i)) {
|
|
219
|
+
i += w.length;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
fail();
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
value();
|
|
227
|
+
ws();
|
|
228
|
+
return i < t.length ? i : t.length;
|
|
229
|
+
}
|
|
230
|
+
catch (at) {
|
|
231
|
+
return typeof at === 'number' ? Math.min(at, t.length) : t.length;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Issue #123: the real file a write to `file` must replace. Symlinks are
|
|
236
|
+
* followed so the link itself is never replaced. A dangling link (target
|
|
237
|
+
* missing) creates the target at the link destination when its directory
|
|
238
|
+
* exists, otherwise throws a clear error (the link is left untouched).
|
|
239
|
+
*/
|
|
240
|
+
/** Follow a symlink chain lexically (works for dangling links). */
|
|
241
|
+
function followSymlinkChain(file) {
|
|
242
|
+
let cur = path.resolve(file);
|
|
243
|
+
for (let hops = 0; hops < 40; hops++) {
|
|
244
|
+
let st;
|
|
245
|
+
try {
|
|
246
|
+
st = fs.lstatSync(cur);
|
|
247
|
+
}
|
|
248
|
+
catch (_) {
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
if (!st.isSymbolicLink())
|
|
252
|
+
break;
|
|
253
|
+
cur = path.resolve(path.dirname(cur), fs.readlinkSync(cur));
|
|
254
|
+
}
|
|
255
|
+
return cur;
|
|
256
|
+
}
|
|
257
|
+
function resolveMocksWriteTarget(file) {
|
|
258
|
+
try {
|
|
259
|
+
return fs.realpathSync(file);
|
|
260
|
+
}
|
|
261
|
+
catch (_) { /* missing or dangling */ }
|
|
262
|
+
const cur = followSymlinkChain(file);
|
|
263
|
+
if (cur !== path.resolve(file)) {
|
|
264
|
+
const parent = path.dirname(cur);
|
|
265
|
+
let parentReal;
|
|
266
|
+
try {
|
|
267
|
+
parentReal = fs.realpathSync(parent);
|
|
268
|
+
}
|
|
269
|
+
catch (_) {
|
|
270
|
+
throw new Error(`Mock 文件是指向不存在位置的符号链接,无法写入: ${file} -> ${cur}(目录 ${parent} 不存在)`);
|
|
271
|
+
}
|
|
272
|
+
return path.join(parentReal, path.basename(cur));
|
|
273
|
+
}
|
|
274
|
+
return cur;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Issue #121: turn the configured `mocksFilePath` into an absolute path.
|
|
278
|
+
* `~` / `~/…` expand to the user's home directory; a relative path is
|
|
279
|
+
* resolved against the meddle directory (MEDDLE_HOME), not the directory the
|
|
280
|
+
* proxy happened to be started from.
|
|
281
|
+
*/
|
|
282
|
+
function resolveConfiguredMocksPath(raw, meddleDir, homeDir = os.homedir()) {
|
|
283
|
+
const v = typeof raw === 'string' ? raw.trim() : '';
|
|
284
|
+
if (!v)
|
|
285
|
+
return null;
|
|
286
|
+
let p = v;
|
|
287
|
+
if (p === '~')
|
|
288
|
+
p = homeDir;
|
|
289
|
+
else if (p.startsWith('~/') || p.startsWith('~\\'))
|
|
290
|
+
p = path.join(homeDir, p.slice(2));
|
|
291
|
+
return path.resolve(meddleDir, p);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Issue #121 migration notice: a relative `mocksFilePath` used to resolve
|
|
295
|
+
* against the proxy's start directory. If the file exists there but not
|
|
296
|
+
* under the new rule (MEDDLE_HOME), report both paths so the user can switch
|
|
297
|
+
* to the old absolute path. Never migrates or rewrites settings.
|
|
298
|
+
*/
|
|
299
|
+
function detectLegacyMocksPath(raw, meddleDir, cwd = process.cwd(), homeDir = os.homedir()) {
|
|
300
|
+
const v = typeof raw === 'string' ? raw.trim() : '';
|
|
301
|
+
if (!v || path.isAbsolute(v) || v === '~' || v.startsWith('~/') || v.startsWith('~\\'))
|
|
302
|
+
return null;
|
|
303
|
+
const newPath = resolveConfiguredMocksPath(v, meddleDir, homeDir);
|
|
304
|
+
const oldPath = path.resolve(cwd, v);
|
|
305
|
+
if (!newPath || oldPath === newPath)
|
|
306
|
+
return null;
|
|
307
|
+
const isFile = (p) => { try {
|
|
308
|
+
return fs.statSync(p).isFile();
|
|
309
|
+
}
|
|
310
|
+
catch (_) {
|
|
311
|
+
return false;
|
|
312
|
+
} };
|
|
313
|
+
if (!isFile(oldPath) || isFile(newPath))
|
|
314
|
+
return null;
|
|
315
|
+
return { configured: v, oldPath, newPath };
|
|
316
|
+
}
|
|
317
|
+
function formatLegacyMocksNotice(l) {
|
|
318
|
+
return `Mock 文件路径「${l.configured}」现在相对于 Meddle 配置目录解析为 ${l.newPath}(不存在);` +
|
|
319
|
+
`按旧规则(相对于启动目录)找到的文件是 ${l.oldPath}。如需继续使用它,请把路径改为该绝对路径。`;
|
|
320
|
+
}
|
|
321
|
+
/** Read the raw (unresolved) mocksFilePath from settings.json. */
|
|
322
|
+
function readConfiguredMocksPath(settingsPath) {
|
|
323
|
+
try {
|
|
324
|
+
if (!fs.existsSync(settingsPath))
|
|
325
|
+
return null;
|
|
326
|
+
const v = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).mocksFilePath;
|
|
327
|
+
return typeof v === 'string' ? v : null;
|
|
328
|
+
}
|
|
329
|
+
catch (_) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
59
333
|
function createMockHandler(ctx) {
|
|
60
334
|
const DEFAULT_MOCK_FILE = path.join(ctx.meddleDir, 'mocks.json');
|
|
61
335
|
function loadCustomPathsFromSettings() {
|
|
62
336
|
try {
|
|
63
337
|
if (fs.existsSync(ctx.settingsPath)) {
|
|
64
338
|
const settings = JSON.parse(fs.readFileSync(ctx.settingsPath, 'utf8'));
|
|
65
|
-
return { mocksFilePath: settings.mocksFilePath
|
|
339
|
+
return { mocksFilePath: resolveConfiguredMocksPath(settings.mocksFilePath, ctx.meddleDir) };
|
|
66
340
|
}
|
|
67
341
|
}
|
|
68
342
|
catch (error) {
|
|
@@ -70,45 +344,165 @@ function createMockHandler(ctx) {
|
|
|
70
344
|
}
|
|
71
345
|
return { mocksFilePath: null };
|
|
72
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Effective mocks file. Issue #118: a configured `mocksFilePath` is honoured
|
|
349
|
+
* even when the file does not exist yet (same as the CLI file mode) — the
|
|
350
|
+
* proxy starts with an empty rule list and the file is created on the first
|
|
351
|
+
* save. Previously a missing custom file silently fell back to the default
|
|
352
|
+
* file, so "switching" to a new file kept reading/writing the old one.
|
|
353
|
+
*/
|
|
73
354
|
function getMockFilePath() {
|
|
74
355
|
if (ctx.currentMocksPath)
|
|
75
356
|
return ctx.currentMocksPath;
|
|
76
357
|
const customPaths = loadCustomPathsFromSettings();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
358
|
+
ctx.currentMocksPath = customPaths.mocksFilePath || DEFAULT_MOCK_FILE;
|
|
359
|
+
return ctx.currentMocksPath;
|
|
360
|
+
}
|
|
361
|
+
/** Path whose contents are currently in ctx.mockRules (null before first load). */
|
|
362
|
+
let loadedMocksPath = null;
|
|
363
|
+
/** Issue #120: parse failure of the loaded file; while set, every write is refused. */
|
|
364
|
+
let parseError = null;
|
|
365
|
+
function getMocksParseError() { return parseError; }
|
|
366
|
+
/**
|
|
367
|
+
* Issue #123 (CI flake root cause): chokidar does not report writes that
|
|
368
|
+
* land while it is still setting up (ignoreInitial). Remember what was
|
|
369
|
+
* loaded so the watcher can re-check once it is ready.
|
|
370
|
+
*/
|
|
371
|
+
let loadedSig = null;
|
|
372
|
+
function fileSig(file) {
|
|
373
|
+
try {
|
|
374
|
+
const st = fs.statSync(file);
|
|
375
|
+
return `${st.ino}:${st.size}:${st.mtimeMs}`;
|
|
376
|
+
}
|
|
377
|
+
catch (_) {
|
|
378
|
+
return 'missing';
|
|
80
379
|
}
|
|
81
|
-
ctx.currentMocksPath = DEFAULT_MOCK_FILE;
|
|
82
|
-
return DEFAULT_MOCK_FILE;
|
|
83
380
|
}
|
|
84
381
|
function loadMockRules() {
|
|
382
|
+
const mockFile = getMockFilePath();
|
|
383
|
+
// Issue #118: switching to another file must not inherit the old
|
|
384
|
+
// file's id counter or rules; re-reading the same file keeps the
|
|
385
|
+
// counter monotonic (issue #115).
|
|
386
|
+
const switched = loadedMocksPath !== null && loadedMocksPath !== mockFile;
|
|
387
|
+
const firstLoadOfThisFile = loadedMocksPath !== mockFile;
|
|
388
|
+
if (switched) {
|
|
389
|
+
proxyDebug(`Mock 文件切换: ${loadedMocksPath} -> ${mockFile}`);
|
|
390
|
+
ctx.mockIdSeq = 1;
|
|
391
|
+
}
|
|
392
|
+
loadedMocksPath = mockFile;
|
|
393
|
+
if (watcherState && watcherState.file !== mockFile)
|
|
394
|
+
startWatcher(mockFile);
|
|
85
395
|
try {
|
|
86
|
-
const mockFile = getMockFilePath();
|
|
87
396
|
if (fs.existsSync(mockFile)) {
|
|
88
|
-
|
|
397
|
+
loadedSig = fileSig(mockFile);
|
|
398
|
+
const parsed = parseMocksFileText(fs.readFileSync(mockFile, 'utf8'));
|
|
399
|
+
if (parsed.error) {
|
|
400
|
+
// Issue #120: never treat a broken file as empty-and-writable.
|
|
401
|
+
const wasSame = parseError && parseError.message === parsed.error.message && parseError.line === parsed.error.line;
|
|
402
|
+
parseError = parsed.error;
|
|
403
|
+
ctx.mockRules = [];
|
|
404
|
+
if (!wasSame)
|
|
405
|
+
console.error(formatMocksParseError(mockFile, parsed.error));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (parseError)
|
|
409
|
+
console.log(`Mock 文件已修复,恢复正常: ${mockFile}`);
|
|
410
|
+
parseError = null;
|
|
411
|
+
const data = parsed.data || {};
|
|
89
412
|
const loaded = Array.isArray(data.rules) ? data.rules : [];
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
413
|
+
// Issue #115: never drop rules that share an id — give the
|
|
414
|
+
// later duplicates fresh ids instead (no silent data loss).
|
|
415
|
+
const { rules, reassigned } = (0, mock_utils_1.ensureUniqueMockRuleIds)(loaded);
|
|
416
|
+
ctx.mockRules = rules;
|
|
417
|
+
if (reassigned > 0) {
|
|
418
|
+
console.warn(`Mock 规则 id 重复或缺失,已为 ${reassigned} 条规则重新分配 id (${mockFile})`);
|
|
93
419
|
}
|
|
94
|
-
|
|
420
|
+
// nextId is the next id to hand out; a stale/missing value must
|
|
421
|
+
// never collide with ids already on disk (issue #115).
|
|
422
|
+
ctx.mockIdSeq = (0, mock_utils_1.nextMockRuleId)(ctx.mockRules, Math.max(Number(data.nextId) || 0, (Number(data.lastId) || 0) + 1, ctx.mockIdSeq || 0));
|
|
95
423
|
proxyDebug(`已加载 ${ctx.mockRules.length} 条 Mock 规则 (${mockFile})`);
|
|
96
424
|
}
|
|
425
|
+
else {
|
|
426
|
+
// File removed / never created — clear in-memory rules (watcher
|
|
427
|
+
// unlink, or a configured path that does not exist yet). The
|
|
428
|
+
// file is created on the first save; nothing else is touched.
|
|
429
|
+
ctx.mockRules = [];
|
|
430
|
+
parseError = null;
|
|
431
|
+
loadedSig = 'missing';
|
|
432
|
+
if (firstLoadOfThisFile && mockFile !== DEFAULT_MOCK_FILE) {
|
|
433
|
+
console.warn(`Mock 规则文件不存在,将以空列表开始,首次保存时创建: ${mockFile}`);
|
|
434
|
+
}
|
|
435
|
+
proxyDebug(`Mock 规则文件不存在,已清空内存规则 (${mockFile})`);
|
|
436
|
+
}
|
|
97
437
|
}
|
|
98
438
|
catch (err) {
|
|
99
439
|
console.error('加载 mock 规则失败:', err.message);
|
|
100
440
|
ctx.mockRules = [];
|
|
101
441
|
}
|
|
102
442
|
}
|
|
443
|
+
function assertMocksWritable() {
|
|
444
|
+
const mockFile = getMockFilePath();
|
|
445
|
+
// Re-validate what is on disk right before writing: the file may have
|
|
446
|
+
// been broken after the last load (watcher not fired yet, stale tab).
|
|
447
|
+
if (!parseError && fs.existsSync(mockFile)) {
|
|
448
|
+
try {
|
|
449
|
+
const parsed = parseMocksFileText(fs.readFileSync(mockFile, 'utf8'));
|
|
450
|
+
if (parsed.error) {
|
|
451
|
+
parseError = parsed.error;
|
|
452
|
+
console.error(formatMocksParseError(mockFile, parsed.error));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch (_) { /* unreadable: let the write report it */ }
|
|
456
|
+
}
|
|
457
|
+
if (parseError)
|
|
458
|
+
throw new MocksFileInvalidError(mockFile, parseError);
|
|
459
|
+
}
|
|
103
460
|
function saveMockRules() {
|
|
461
|
+
// Issue #120: single choke point for every write — refuse to overwrite a broken file.
|
|
462
|
+
assertMocksWritable();
|
|
104
463
|
try {
|
|
105
|
-
|
|
464
|
+
// Issue #115: keep every rule (re-id duplicates instead of dropping
|
|
465
|
+
// them) and persist a sequence that is above every stored id.
|
|
466
|
+
ctx.mockRules = (0, mock_utils_1.ensureUniqueMockRuleIds)(ctx.mockRules).rules;
|
|
467
|
+
ctx.mockIdSeq = (0, mock_utils_1.nextMockRuleId)(ctx.mockRules, ctx.mockIdSeq);
|
|
106
468
|
const mockFile = getMockFilePath();
|
|
107
|
-
|
|
469
|
+
const dir = path.dirname(mockFile);
|
|
470
|
+
if (!fs.existsSync(dir))
|
|
471
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
472
|
+
// Issue #123: write next to the real file so a symlinked mocks file
|
|
473
|
+
// (e.g. into a dotfiles repo) stays a symlink, and keep its mode/owner.
|
|
474
|
+
const target = resolveMocksWriteTarget(mockFile);
|
|
475
|
+
let existing = null;
|
|
476
|
+
try {
|
|
477
|
+
existing = fs.statSync(target);
|
|
478
|
+
}
|
|
479
|
+
catch (_) { /* new file */ }
|
|
480
|
+
// Write to a temp file then rename, so a crash never leaves half a file (issue #120).
|
|
481
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
482
|
+
fs.writeFileSync(tmp, JSON.stringify({ nextId: ctx.mockIdSeq, rules: ctx.mockRules }, null, 2), { encoding: 'utf8', mode: existing ? existing.mode & 0o7777 : 0o666 });
|
|
483
|
+
try {
|
|
484
|
+
if (existing) {
|
|
485
|
+
fs.chmodSync(tmp, existing.mode & 0o7777);
|
|
486
|
+
try {
|
|
487
|
+
fs.chownSync(tmp, existing.uid, existing.gid);
|
|
488
|
+
}
|
|
489
|
+
catch (_) { /* not permitted: best effort */ }
|
|
490
|
+
}
|
|
491
|
+
fs.renameSync(tmp, target);
|
|
492
|
+
loadedSig = fileSig(mockFile);
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
try {
|
|
496
|
+
fs.unlinkSync(tmp);
|
|
497
|
+
}
|
|
498
|
+
catch (_) { /* ignore */ }
|
|
499
|
+
throw e;
|
|
500
|
+
}
|
|
108
501
|
proxyDebug(`Mock 规则已保存到 ${mockFile}`);
|
|
109
502
|
}
|
|
110
503
|
catch (err) {
|
|
111
504
|
console.error('保存 mock 规则失败:', err.message);
|
|
505
|
+
throw err;
|
|
112
506
|
}
|
|
113
507
|
}
|
|
114
508
|
function matchMockRule(url, method) {
|
|
@@ -117,25 +511,23 @@ function createMockHandler(ctx) {
|
|
|
117
511
|
return false;
|
|
118
512
|
if (rule.method && rule.method !== '*' && rule.method.toUpperCase() !== method.toUpperCase())
|
|
119
513
|
return false;
|
|
120
|
-
|
|
121
|
-
return
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
514
|
+
if (!(0, mock_utils_1.matchUrlPattern)(url, rule.urlPattern))
|
|
515
|
+
return false;
|
|
516
|
+
if (!(0, mock_utils_1.matchQueryCondition)(url, rule.query))
|
|
517
|
+
return false;
|
|
518
|
+
return true;
|
|
126
519
|
}) || null;
|
|
127
520
|
}
|
|
128
|
-
function buildMockResponseForTest(rule) {
|
|
521
|
+
function buildMockResponseForTest(rule, requestHeaders) {
|
|
129
522
|
const statusCode = rule.statusCode || 200;
|
|
130
523
|
const ruleHeaders = {};
|
|
131
524
|
for (const [key, value] of Object.entries(rule.headers || {})) {
|
|
132
525
|
ruleHeaders[key.toLowerCase()] = value;
|
|
133
526
|
}
|
|
134
|
-
const headers = {
|
|
527
|
+
const headers = (0, mock_utils_1.resolveHeaderPlaceholders)((0, mock_utils_1.mergeResponseHeaders)({
|
|
135
528
|
'x-mock-rule': encodeURIComponent(rule.name || rule.id?.toString() || rule.urlPattern || ''),
|
|
136
|
-
'content-type':
|
|
137
|
-
|
|
138
|
-
};
|
|
529
|
+
'content-type': 'application/json',
|
|
530
|
+
}, ruleHeaders), requestHeaders);
|
|
139
531
|
let body = '';
|
|
140
532
|
if (rule.bodyType === 'file' && rule.body) {
|
|
141
533
|
let filePath = rule.body.replace(/^file:\/\//, '').replace(/^\/[A-Za-z]:\//, '');
|
|
@@ -171,13 +563,12 @@ function createMockHandler(ctx) {
|
|
|
171
563
|
for (const [key, value] of Object.entries(rule.headers || {})) {
|
|
172
564
|
ruleHeaders[key.toLowerCase()] = value;
|
|
173
565
|
}
|
|
174
|
-
const responseHeaders = {
|
|
566
|
+
const responseHeaders = (0, mock_utils_1.resolveHeaderPlaceholders)((0, mock_utils_1.mergeResponseHeaders)({
|
|
175
567
|
'X-Mock-Rule': encodeURIComponent(mockRuleName),
|
|
176
568
|
'Access-Control-Allow-Origin': '*',
|
|
177
569
|
'Access-Control-Allow-Methods': '*',
|
|
178
570
|
'Access-Control-Allow-Headers': '*',
|
|
179
|
-
|
|
180
|
-
};
|
|
571
|
+
}, ruleHeaders), req.headers);
|
|
181
572
|
let finalStatusCode = statusCode;
|
|
182
573
|
let statusMessage = 'OK (Mock)';
|
|
183
574
|
if (isFileBody) {
|
|
@@ -338,9 +729,230 @@ function createMockHandler(ctx) {
|
|
|
338
729
|
doSend();
|
|
339
730
|
}
|
|
340
731
|
}
|
|
732
|
+
/** Active file watcher (issue #118: follows the effective mocks file). */
|
|
733
|
+
let watcherState = null;
|
|
734
|
+
let watcherOnReload;
|
|
735
|
+
function startWatcher(mockFile) {
|
|
736
|
+
if (watcherState) {
|
|
737
|
+
watcherState.close();
|
|
738
|
+
proxyDebug(`stopped watching mocks file: ${watcherState.file}`);
|
|
739
|
+
}
|
|
740
|
+
const dir = path.dirname(mockFile);
|
|
741
|
+
const base = path.basename(mockFile);
|
|
742
|
+
try {
|
|
743
|
+
if (!fs.existsSync(dir))
|
|
744
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
745
|
+
}
|
|
746
|
+
catch (_) { /* ignore */ }
|
|
747
|
+
const debugWatch = process.env.MEDDLE_DEBUG_WATCH === '1';
|
|
748
|
+
const t0 = Date.now();
|
|
749
|
+
const dbg = (msg) => { if (debugWatch)
|
|
750
|
+
console.error(`[watch +${Date.now() - t0}ms] ${msg}`); };
|
|
751
|
+
// Issue #123: when the mocks path is a symlink, edits land on the real
|
|
752
|
+
// target (git pull in a dotfiles repo, fixing JSON there), so watch the
|
|
753
|
+
// target's directory too and accept events for either path.
|
|
754
|
+
const resolveTarget = () => {
|
|
755
|
+
try {
|
|
756
|
+
return resolveMocksWriteTarget(mockFile);
|
|
757
|
+
}
|
|
758
|
+
catch (_) {
|
|
759
|
+
// Issue #125: a dangling link whose target directory does not
|
|
760
|
+
// exist yet still has a destination to wait for.
|
|
761
|
+
return followSymlinkChain(mockFile);
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
let target = resolveTarget();
|
|
765
|
+
let targetDir = null;
|
|
766
|
+
// Watch the directory (not only the file) so atomic replace / recreate
|
|
767
|
+
// still triggers — same pattern as route-rules watcher.
|
|
768
|
+
const initialTargetDir = path.dirname(target);
|
|
769
|
+
targetDir = path.resolve(initialTargetDir) !== path.resolve(dir) && fs.existsSync(initialTargetDir) ? initialTargetDir : null;
|
|
770
|
+
// Both directories go into the initial watch so they are ready together.
|
|
771
|
+
const watcher = chokidar_1.default.watch(targetDir ? [dir, targetDir] : dir, {
|
|
772
|
+
ignoreInitial: true,
|
|
773
|
+
depth: 0,
|
|
774
|
+
// Follow the symlink target explicitly (below) instead of relying on
|
|
775
|
+
// chokidar's implicit following, which differs per platform backend.
|
|
776
|
+
followSymlinks: false,
|
|
777
|
+
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
|
|
778
|
+
});
|
|
779
|
+
// Catch up on a write chokidar did not report because it landed while a
|
|
780
|
+
// directory watch was still being set up (ignoreInitial).
|
|
781
|
+
const catchUp = (why) => {
|
|
782
|
+
if (closed || !watcherState || watcherState.file !== mockFile)
|
|
783
|
+
return;
|
|
784
|
+
recheckTargetDir(why);
|
|
785
|
+
if (fileSig(mockFile) !== loadedSig)
|
|
786
|
+
reload(why);
|
|
787
|
+
};
|
|
788
|
+
// Issue #125: the target directory may not exist yet (dangling link to a
|
|
789
|
+
// repo that is cloned later). Watch its nearest existing ancestor
|
|
790
|
+
// instead and move on to the real directory once it shows up.
|
|
791
|
+
// A separate watcher instance is used for the ancestor: chokidar 3's
|
|
792
|
+
// unwatch() adds '<dir>/**' to the ignore list, which would also
|
|
793
|
+
// swallow every later event for the link / target directories below it.
|
|
794
|
+
let pendingAncestor = null;
|
|
795
|
+
let ancestorWatcher = null;
|
|
796
|
+
const isWithin = (parent, child) => {
|
|
797
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
798
|
+
return rel === '' || !(rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel));
|
|
799
|
+
};
|
|
800
|
+
const nearestExistingAncestor = (p) => {
|
|
801
|
+
let cur = path.dirname(p);
|
|
802
|
+
while (!fs.existsSync(cur) && path.dirname(cur) !== cur)
|
|
803
|
+
cur = path.dirname(cur);
|
|
804
|
+
return cur;
|
|
805
|
+
};
|
|
806
|
+
const stopAncestorWatch = () => {
|
|
807
|
+
if (ancestorWatcher)
|
|
808
|
+
ancestorWatcher.close().catch(() => { });
|
|
809
|
+
ancestorWatcher = null;
|
|
810
|
+
pendingAncestor = null;
|
|
811
|
+
};
|
|
812
|
+
const watchTargetDir = (force = false) => {
|
|
813
|
+
const want = path.dirname(target);
|
|
814
|
+
const next = path.resolve(want) === path.resolve(dir) ? null : want;
|
|
815
|
+
const exists = !!next && fs.existsSync(next);
|
|
816
|
+
if (!force && next === targetDir && (exists || !next) && !pendingAncestor)
|
|
817
|
+
return;
|
|
818
|
+
stopAncestorWatch();
|
|
819
|
+
if (targetDir && (targetDir !== next || !exists))
|
|
820
|
+
watcher.unwatch(targetDir);
|
|
821
|
+
targetDir = next;
|
|
822
|
+
if (targetDir && exists) {
|
|
823
|
+
watcher.add(targetDir);
|
|
824
|
+
// chokidar emits no 'ready' for add(); re-check a few times
|
|
825
|
+
// while the new directory watch comes up.
|
|
826
|
+
for (const ms of [100, 300, 1000]) {
|
|
827
|
+
setTimeout(() => catchUp('changed while target watch was starting'), ms).unref?.();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
else if (targetDir) {
|
|
831
|
+
// Issue #125: the target directory does not exist (yet, or any
|
|
832
|
+
// more): wait for it in its nearest existing ancestor.
|
|
833
|
+
const ancestor = nearestExistingAncestor(targetDir);
|
|
834
|
+
pendingAncestor = ancestor;
|
|
835
|
+
const aw = chokidar_1.default.watch(ancestor, { ignoreInitial: true, depth: 0, followSymlinks: false });
|
|
836
|
+
aw.on('addDir', (p) => {
|
|
837
|
+
if (closed || ancestorWatcher !== aw || !targetDir)
|
|
838
|
+
return;
|
|
839
|
+
if (isWithin(p, targetDir))
|
|
840
|
+
watchTargetDir(true);
|
|
841
|
+
});
|
|
842
|
+
// Issue #141: the ancestor itself can vanish too — `rm -rf repo`
|
|
843
|
+
// removes repo/dotfiles first, and if that unlinkDir is handled
|
|
844
|
+
// before repo is gone, the ancestor picked here is repo, which a
|
|
845
|
+
// moment later no longer exists: a watch on a deleted directory
|
|
846
|
+
// never fires again. The directory may also have appeared while
|
|
847
|
+
// this watch was starting. Re-validate on ready and on any raw
|
|
848
|
+
// event (inotify reports the watched directory's own removal).
|
|
849
|
+
const recheck = (why) => { if (!closed && ancestorWatcher === aw)
|
|
850
|
+
recheckTargetDir(why); };
|
|
851
|
+
aw.on('ready', () => { dbg(`ancestor watch ready: ${ancestor}`); recheck('ancestor watch ready'); });
|
|
852
|
+
aw.on('unlinkDir', () => recheck('ancestor directory removed'));
|
|
853
|
+
aw.on('raw', () => recheck('ancestor raw event'));
|
|
854
|
+
aw.on('error', () => recheck('ancestor watch error'));
|
|
855
|
+
ancestorWatcher = aw;
|
|
856
|
+
}
|
|
857
|
+
dbg(`target ${target}; extra dir ${targetDir ?? '(none)'}${pendingAncestor ? ` (missing; waiting in ${pendingAncestor})` : ''}`);
|
|
858
|
+
};
|
|
859
|
+
/**
|
|
860
|
+
* Issue #141: make sure the watch plan still matches the disk. chokidar
|
|
861
|
+
* does not report every directory removal: a target directory removed
|
|
862
|
+
* while the watch is still starting yields no unlinkDir, and an
|
|
863
|
+
* ancestor removed right after it was picked leaves a dead watch.
|
|
864
|
+
* Either way the recreated directory would never be noticed again.
|
|
865
|
+
*/
|
|
866
|
+
const recheckTargetDir = (why) => {
|
|
867
|
+
if (closed || !targetDir)
|
|
868
|
+
return;
|
|
869
|
+
const stale = pendingAncestor
|
|
870
|
+
? fs.existsSync(targetDir) || nearestExistingAncestor(targetDir) !== pendingAncestor
|
|
871
|
+
: !fs.existsSync(targetDir);
|
|
872
|
+
if (!stale)
|
|
873
|
+
return;
|
|
874
|
+
dbg(`re-planning target watch (${why})`);
|
|
875
|
+
watchTargetDir(true);
|
|
876
|
+
};
|
|
877
|
+
watchTargetDir();
|
|
878
|
+
let closed = false;
|
|
879
|
+
let trailingCheck = null;
|
|
880
|
+
const reload = (event, changedPath) => {
|
|
881
|
+
// Ignore late events from a watcher that was replaced, and any
|
|
882
|
+
// file in the directory other than the active mocks file / its target.
|
|
883
|
+
if (closed || !watcherState || watcherState.file !== mockFile)
|
|
884
|
+
return;
|
|
885
|
+
if (changedPath) {
|
|
886
|
+
const p = path.resolve(changedPath);
|
|
887
|
+
if (p !== path.resolve(mockFile) && p !== target)
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
// The link itself may have been retargeted: follow the new target.
|
|
891
|
+
const nextTarget = resolveTarget();
|
|
892
|
+
if (nextTarget !== target) {
|
|
893
|
+
target = nextTarget;
|
|
894
|
+
watchTargetDir();
|
|
895
|
+
}
|
|
896
|
+
else
|
|
897
|
+
recheckTargetDir(event);
|
|
898
|
+
proxyDebug(`mocks file ${event}: ${base}`);
|
|
899
|
+
loadMockRules();
|
|
900
|
+
if (watcherOnReload)
|
|
901
|
+
watcherOnReload();
|
|
902
|
+
// Issue #116: chokidar throttles raw fs events per file on the
|
|
903
|
+
// leading edge (5ms, no trailing emit), so a write landing right
|
|
904
|
+
// after the one being handled (e.g. broken -> fixed a few ms apart,
|
|
905
|
+
// especially while the watcher is still starting) can be dropped.
|
|
906
|
+
// Re-check the file signature once the burst is over.
|
|
907
|
+
if (trailingCheck)
|
|
908
|
+
clearTimeout(trailingCheck);
|
|
909
|
+
trailingCheck = setTimeout(() => { trailingCheck = null; catchUp('changed during a burst of writes'); }, 150);
|
|
910
|
+
trailingCheck.unref?.();
|
|
911
|
+
};
|
|
912
|
+
if (debugWatch) {
|
|
913
|
+
watcher.on('all', (ev, p) => dbg(`${ev} ${p}`));
|
|
914
|
+
}
|
|
915
|
+
watcher.on('ready', () => {
|
|
916
|
+
dbg('ready');
|
|
917
|
+
catchUp('changed before watcher ready');
|
|
918
|
+
});
|
|
919
|
+
watcher.on('change', (p) => reload('changed', p));
|
|
920
|
+
watcher.on('add', (p) => reload('added', p));
|
|
921
|
+
watcher.on('unlink', (p) => reload('removed', p));
|
|
922
|
+
// Issue #125: the target directory itself was removed (repo deleted /
|
|
923
|
+
// re-cloned): fall back to waiting for it in its nearest ancestor.
|
|
924
|
+
watcher.on('unlinkDir', (p) => {
|
|
925
|
+
if (closed || !targetDir)
|
|
926
|
+
return;
|
|
927
|
+
if (path.resolve(p) === path.resolve(targetDir))
|
|
928
|
+
watchTargetDir(true);
|
|
929
|
+
});
|
|
930
|
+
watcherState = {
|
|
931
|
+
file: mockFile,
|
|
932
|
+
close: () => {
|
|
933
|
+
closed = true;
|
|
934
|
+
if (trailingCheck)
|
|
935
|
+
clearTimeout(trailingCheck);
|
|
936
|
+
stopAncestorWatch();
|
|
937
|
+
watcher.close().catch(() => { });
|
|
938
|
+
},
|
|
939
|
+
};
|
|
940
|
+
proxyDebug(`watching mocks file: ${mockFile}`);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Watch the mocks file for external edits (CLI file-mode / hand edits).
|
|
944
|
+
* Aligns with route-rules watcher so file-only writes still hot-reload (issue #85).
|
|
945
|
+
* When the effective mocks file changes (issue #118), loadMockRules moves
|
|
946
|
+
* the watcher to the new file automatically.
|
|
947
|
+
*/
|
|
948
|
+
function initMockFileWatcher(onReload) {
|
|
949
|
+
watcherOnReload = onReload;
|
|
950
|
+
startWatcher(getMockFilePath());
|
|
951
|
+
}
|
|
341
952
|
return {
|
|
342
|
-
getMockFilePath, loadMockRules, saveMockRules, matchMockRule,
|
|
953
|
+
getMockFilePath, loadMockRules, saveMockRules, assertMocksWritable, getMocksParseError, matchMockRule,
|
|
343
954
|
buildMockResponseForTest, sendMockResponse, loadCustomPathsFromSettings,
|
|
955
|
+
initMockFileWatcher,
|
|
344
956
|
};
|
|
345
957
|
}
|
|
346
958
|
//# sourceMappingURL=mock-handler.js.map
|