dsh-api-runner 0.0.0-stage → 0.2.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/src/config.js ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * 配置加载:默认路径 ${DSH_HOME}/api-runner.yml,也支持插件 Config 指定路径。
3
+ *
4
+ * 解析顺序(后者覆盖前者):
5
+ * 1. 内置默认值
6
+ * 2. 全局配置 `${DSH_HOME}/api-runner.yml`(不存在则跳过)
7
+ * 3. 项目配置 `<project>/.dsh/api-runner.yml`(存在则覆盖,便于按仓库差异)
8
+ * 每次读取都重新解析(带 mtime 缓存),改配置不需要重启 DSH。
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { parse as parseYaml } from 'yaml';
13
+ import { deepMerge, expandDeep, expandDollar, isPlainObject, resolveFrom } from './util.js';
14
+
15
+ export const DEFAULT_CONFIG = {
16
+ /** 配置级默认请求设置,接口可单独覆盖 */
17
+ defaults: {
18
+ timeoutMs: 30_000,
19
+ headers: {},
20
+ /** 只对这些 header 保留,不填则全保留 */
21
+ keepHeaders: [],
22
+ },
23
+ /** 命名环境变量组:接口 url/headers 里可用 ${NAME} 引用 */
24
+ env: {},
25
+ /** 接口清单 */
26
+ apis: [],
27
+ /** 服务清单 */
28
+ services: [],
29
+ /** 日志清单 */
30
+ logs: [],
31
+ };
32
+
33
+ /** 候选文件名(按优先级) */
34
+ const CONFIG_FILES = ['api-runner.yml', 'api-runner.yaml', 'dsh-api-runner.yml'];
35
+
36
+ export class ConfigStore {
37
+ constructor(options = {}) {
38
+ this.homeDir = options.homeDir || process.env.DSH_HOME || path.join(process.cwd(), '.dsh');
39
+ this.explicitPath = options.configPath || '';
40
+ this._cache = new Map();
41
+ }
42
+
43
+ /** 全局配置路径 */
44
+ globalPath() {
45
+ if (this.explicitPath) return resolveFrom(this.homeDir, this.explicitPath);
46
+ for (const name of CONFIG_FILES) {
47
+ const candidate = path.join(this.homeDir, name);
48
+ if (this._isFile(candidate)) return candidate;
49
+ }
50
+ return path.join(this.homeDir, CONFIG_FILES[0]);
51
+ }
52
+
53
+ /** 项目级配置路径(存在才返回) */
54
+ projectPath(cwd) {
55
+ if (!cwd) return '';
56
+ for (const name of CONFIG_FILES) {
57
+ const candidate = path.join(cwd, '.dsh', name);
58
+ if (this._isFile(candidate)) return candidate;
59
+ }
60
+ return '';
61
+ }
62
+
63
+ /**
64
+ * 读取生效配置。返回 { config, sources, warnings }
65
+ * cwd 用于定位项目级配置与相对路径解析基准。
66
+ * extra 是最后一个覆盖层(侧边栏面板里维护的配置),优先级最高。
67
+ */
68
+ load(cwd = '', extra = undefined) {
69
+ const warnings = [];
70
+ const sources = [];
71
+ let config = structuredClone(DEFAULT_CONFIG);
72
+
73
+ for (const file of [this.globalPath(), this.projectPath(cwd)].filter(Boolean)) {
74
+ if (!this._isFile(file)) continue;
75
+ const parsed = this._readYaml(file, warnings);
76
+ if (parsed && isPlainObject(parsed)) {
77
+ config = deepMerge(config, parsed);
78
+ sources.push(file);
79
+ }
80
+ }
81
+
82
+ if (isPlainObject(extra)) {
83
+ // 面板配置里的 apis 要按 id 与 YAML 合并(同 id 面板覆盖),
84
+ // 不能像 deepMerge 那样整体替换数组——否则面板一存,YAML 里的接口全消失。
85
+ const { apis: extraApis, ...rest } = extra;
86
+ config = deepMerge(config, rest);
87
+ if (Array.isArray(extraApis) && extraApis.length > 0) {
88
+ const byId = new Map();
89
+ for (const item of Array.isArray(config.apis) ? config.apis : []) {
90
+ if (isPlainObject(item)) byId.set(String(item.id || item.name || ''), item);
91
+ }
92
+ for (const item of extraApis) {
93
+ if (isPlainObject(item)) byId.set(String(item.id || item.name || ''), item);
94
+ }
95
+ config.apis = [...byId.values()];
96
+ }
97
+ sources.push('(面板配置)');
98
+ }
99
+
100
+ const baseDir = cwd || path.dirname(sources[sources.length - 1] || this.globalPath());
101
+ return {
102
+ config: this._normalize(config, baseDir, warnings),
103
+ sources,
104
+ baseDir,
105
+ warnings,
106
+ };
107
+ }
108
+
109
+ _readYaml(file, warnings) {
110
+ let stat;
111
+ try {
112
+ stat = fs.statSync(file);
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ const cached = this._cache.get(file);
117
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.value;
118
+ try {
119
+ const text = fs.readFileSync(file, 'utf8');
120
+ const value = parseYaml(text);
121
+ this._cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, value });
122
+ return value;
123
+ } catch (error) {
124
+ warnings.push(`解析配置失败 ${file}: ${error.message}`);
125
+ this._cache.delete(file);
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ _isFile(file) {
131
+ try {
132
+ return fs.statSync(file).isFile();
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /** 规整为运行期结构:补 id、解析相对日志路径、展开环境引用 */
139
+ _normalize(config, baseDir, warnings) {
140
+ const scope = { ...(isPlainObject(config.env) ? config.env : {}), ...process.env };
141
+ const defaults = isPlainObject(config.defaults) ? config.defaults : {};
142
+
143
+ const apis = (Array.isArray(config.apis) ? config.apis : [])
144
+ .filter((item) => isPlainObject(item))
145
+ .map((item, index) => {
146
+ const id = String(item.id || item.name || `api-${index + 1}`).trim();
147
+ const headers = expandDeep(deepMerge(isPlainObject(defaults.headers) ? defaults.headers : {}, item.headers), scope);
148
+ return {
149
+ id,
150
+ name: String(item.name || id),
151
+ description: String(item.description || ''),
152
+ method: String(item.method || 'GET').toUpperCase(),
153
+ url: String(expandDollar(item.url || '', scope)),
154
+ headers: isPlainObject(headers) ? headers : {},
155
+ query: isPlainObject(item.query) ? item.query : {},
156
+ body: item.body,
157
+ timeoutMs: Number(item.timeoutMs || defaults.timeoutMs || 30_000),
158
+ /** baseUrl 前缀,可选 */
159
+ baseUrl: String(expandDollar(item.baseUrl || '', scope)),
160
+ /** 登录接口声明:调用成功后从响应里取 token 并保存 */
161
+ login: isPlainObject(item.login)
162
+ ? {
163
+ /** 响应 JSON 里的取值路径,如 data.token */
164
+ tokenPath: String(item.login.tokenPath || 'token'),
165
+ /** token 保存后自动注入的请求头 */
166
+ header: String(item.login.header || 'Authorization'),
167
+ /** 头值前缀,如 "Bearer ";不需要前缀就写空串 */
168
+ prefix: String(item.login.prefix ?? 'Bearer '),
169
+ /** 保存后不再注入(纯展示用);一般不需要 */
170
+ disabled: item.login.disabled === true,
171
+ }
172
+ : undefined,
173
+ /** 调用本接口时注入哪个登录接口保存的 token(id);留空则自动匹配唯一可用 token */
174
+ useToken: String(item.useToken || ''),
175
+ /** 面板专用的原文/示例字段,YAML 里写了也保留,方便面板回显 */
176
+ headersText: typeof item.headersText === 'string' ? item.headersText : undefined,
177
+ bodyText: typeof item.bodyText === 'string' ? item.bodyText : undefined,
178
+ sampleText: typeof item.sampleText === 'string' ? item.sampleText : undefined,
179
+ /** 示例参数:call/面板测试未提供参数时用作默认值 */
180
+ sampleParams: isPlainObject(item.sampleParams) ? item.sampleParams : undefined,
181
+ /** 来自面板还是 YAML 文件 */
182
+ source: item.source === 'ui' ? 'ui' : undefined,
183
+ };
184
+ });
185
+
186
+ const services = (Array.isArray(config.services) ? config.services : [])
187
+ .filter((item) => isPlainObject(item))
188
+ .map((item, index) => {
189
+ const id = String(item.id || item.name || `service-${index + 1}`).trim();
190
+ const mode = String(item.mode || 'observe').toLowerCase();
191
+ const health = isPlainObject(item.health) ? item.health : {};
192
+ return {
193
+ id,
194
+ name: String(item.name || id),
195
+ mode: mode === 'managed' ? 'managed' : 'observe',
196
+ cwd: resolveFrom(baseDir, String(item.cwd || '')),
197
+ command: String(item.command || ''),
198
+ args: (Array.isArray(item.args) ? item.args : []).map(String),
199
+ env: isPlainObject(item.env) ? item.env : {},
200
+ /** managed:启动后等待健康检查通过的最长时间 */
201
+ startupTimeoutMs: Number(item.startupTimeoutMs || 60_000),
202
+ /** managed:重启前等待旧进程退出的时间 */
203
+ stopTimeoutMs: Number(item.stopTimeoutMs || 10_000),
204
+ /** 停止时优先整棵进程树 */
205
+ killTree: item.killTree !== false,
206
+ /** managed:子进程 stdout/stderr 追加写入的文件(可选) */
207
+ logPath: resolveFrom(baseDir, String(expandDollar(item.logPath || '', scope))),
208
+ health: {
209
+ url: String(expandDollar(health.url || '', scope)),
210
+ method: String(health.method || 'GET').toUpperCase(),
211
+ expectStatus: Number(health.expectStatus || 0),
212
+ timeoutMs: Number(health.timeoutMs || 3_000),
213
+ intervalMs: Number(health.intervalMs || 1_000),
214
+ /** observe 模式额外探测的 TCP 端口 */
215
+ ports: (Array.isArray(health.ports) ? health.ports : []).map(Number).filter(Boolean),
216
+ host: String(health.host || '127.0.0.1'),
217
+ },
218
+ };
219
+ });
220
+
221
+ const logs = (Array.isArray(config.logs) ? config.logs : [])
222
+ .filter((item) => isPlainObject(item))
223
+ .map((item, index) => {
224
+ const id = String(item.id || item.name || `log-${index + 1}`).trim();
225
+ return {
226
+ id,
227
+ name: String(item.name || id),
228
+ path: resolveFrom(baseDir, String(expandDollar(item.path || '', scope))),
229
+ /** 本插件维护的读取游标文件(可选) */
230
+ encoding: String(item.encoding || 'utf8'),
231
+ description: String(item.description || ''),
232
+ };
233
+ });
234
+
235
+ const seen = new Set();
236
+ for (const item of [...apis, ...services, ...logs]) {
237
+ if (seen.has(item.id)) warnings.push(`配置 id 重复:${item.id}(后出现的会覆盖前者)`);
238
+ seen.add(item.id);
239
+ }
240
+
241
+ return {
242
+ defaults,
243
+ env: isPlainObject(config.env) ? config.env : {},
244
+ apis,
245
+ services,
246
+ logs,
247
+ };
248
+ }
249
+
250
+ /** 按 id 找接口(支持 name 别名) */
251
+ findApi(config, id) {
252
+ const key = String(id || '').trim();
253
+ return config.apis.find((item) => item.id === key || item.name === key);
254
+ }
255
+
256
+ findService(config, id) {
257
+ const key = String(id || '').trim();
258
+ return config.services.find((item) => item.id === key || item.name === key);
259
+ }
260
+
261
+ findLog(config, id) {
262
+ const key = String(id || '').trim();
263
+ return config.logs.find((item) => item.id === key || item.name === key);
264
+ }
265
+ }