@mokuyoaxis/dsh-iris 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.js ADDED
@@ -0,0 +1,467 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 工作台 —— Host 侧状态 API。
4
+ *
5
+ * 数据通路完全复用 `/iris/media` 的宿内 webServer 前缀路由模式,
6
+ * 只新增一个 JSON 出口:GET /iris/api/state。
7
+ *
8
+ * 输出纪律(与整仓一致):
9
+ * - 只回标量字段(字符串/数字/布尔/受限数组),apiKey 永不明文,只给 hint;
10
+ * - 文件路径只给相对 outputs/ 的 basename + 授权播放链接(token 随机 128bit);
11
+ * - tasks/providers 读的是 $DSH_HOME/iris/v1 的持久 JSON,与任务盯守共用同一存储。
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { irisHome, capabilitiesOf, onChange as onConfigChange } from './config.js';
16
+ import { mimeOf, mediaUrl } from './media.js';
17
+ import * as tasks from './tasks.js';
18
+ import { runAction } from './actions.js';
19
+
20
+ function readJsonSafe(file) {
21
+ try {
22
+ const raw = fs.readFileSync(file, 'utf8');
23
+ const obj = JSON.parse(raw);
24
+ return obj && typeof obj === 'object' ? obj : null;
25
+ } catch (_) {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** Key 只给头尾提示,绝不回明文 */
31
+ function keyHint(key) {
32
+ const t = String(key || '');
33
+ if (!t) return '';
34
+ if (t.length <= 8) return '****';
35
+ return `${t.slice(0, 3)}****${t.slice(-4)}`;
36
+ }
37
+
38
+ function providerPublic(p) {
39
+ return {
40
+ id: p.id,
41
+ name: p.name || p.id,
42
+ type: p.type || 'openai',
43
+ baseUrl: p.baseUrl,
44
+ enabled: p.enabled !== false,
45
+ mediaProtocol: p.mediaProtocol || 'dashscope',
46
+ apiKeyHint: keyHint(p.apiKey),
47
+ capabilities: capabilitiesOf(p),
48
+ // 迁移/推断结果是否与原始声明不同(UI 提示旧配置已推断)
49
+ capabilityInferred: !Array.isArray(p.capabilities) || p.capabilities.length === 0,
50
+ imageModel: p.imageModel || '',
51
+ videoModel: p.videoModel || '',
52
+ ttsModel: p.ttsModel || '',
53
+ transcribeModel: p.transcribeModel || '',
54
+ visionModel: p.visionModel || ''
55
+ };
56
+ }
57
+
58
+ function mediaOf(task) {
59
+ const list = Array.isArray(task.media) ? task.media : [];
60
+ return list
61
+ .filter((e) => e && e.token && e.file)
62
+ .map((e) => ({
63
+ file: e.file,
64
+ mime: e.mime || mimeOf(e.file),
65
+ url: mediaUrl(task.id, e)
66
+ }));
67
+ }
68
+
69
+ function taskPublic(t) {
70
+ const elapsedMs =
71
+ t.elapsedMs ||
72
+ (t.finishedAt && t.createdAt ? new Date(t.finishedAt).getTime() - new Date(t.createdAt).getTime() : undefined);
73
+ return {
74
+ id: t.id,
75
+ cap: t.cap || '',
76
+ status: t.status || 'running',
77
+ progress: t.progress || '',
78
+ model: t.model || '',
79
+ providerName: t.providerName || '',
80
+ prompt: String(t.prompt || '').slice(0, 120),
81
+ createdAt: t.createdAt || '',
82
+ finishedAt: t.finishedAt || '',
83
+ elapsedMs,
84
+ error: t.error || '',
85
+ saved: !!t.saved,
86
+ expired: !!t.expired,
87
+ mode: t.mode || '',
88
+ files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
89
+ media: mediaOf(t)
90
+ };
91
+ }
92
+
93
+ function loadTasks() {
94
+ const cache = readJsonSafe(path.join(irisHome(), 'tasks.json'));
95
+ const tasks = Array.isArray(cache && cache.tasks) ? cache.tasks : [];
96
+ return tasks;
97
+ }
98
+
99
+ function loadProviders() {
100
+ const cache = readJsonSafe(path.join(irisHome(), 'providers.json'));
101
+ return Array.isArray(cache && cache.providers) ? cache.providers : [];
102
+ }
103
+
104
+ /** 构建完整工作台状态(纯 JSON,可序列化) */
105
+ export function buildState() {
106
+ const tasks = loadTasks();
107
+ const providers = loadProviders();
108
+
109
+ const all = [...tasks].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
110
+ const running = all.filter((t) => t.status === 'running' && !t.expired);
111
+ const terminal = all.filter((t) => t.status !== 'running');
112
+ const recent = terminal.slice(0, 100).map(taskPublic);
113
+
114
+ return {
115
+ providers: providers.map(providerPublic),
116
+ tasks: {
117
+ recent,
118
+ recentTotal: terminal.length, // 泡泡「查看全部 (N)」计数用(不受 slice 截断)
119
+ running: running.slice(0, 10).map(taskPublic)
120
+ }
121
+ };
122
+ }
123
+
124
+ /* ---------------- SSE 共享状态推送(阶段 4:状态变化即推,替代 5s 轮询) ---------------- */
125
+
126
+ /** 当前活跃 SSE 连接数(生命周期成本测量:连接管理面) */
127
+ export function sseClientCount() {
128
+ return sseClients.size;
129
+ }
130
+
131
+ const sseClients = new Set(); // 活跃 res(长连接)
132
+ let sseHeartbeat = null; // 心跳定时器(15s,防代理/浏览器空闲断连)
133
+ let sseThrottleTimer = null; // 推送节流(400ms 合并,防盯守高频 update 刷屏)
134
+ let ssePending = false;
135
+ let sseBound = false;
136
+
137
+ function pushSse(res, data) {
138
+ if (!res || res.writableEnded || res.destroyed) return;
139
+ try {
140
+ res.write(`data: ${data}\n\n`);
141
+ } catch (_) {
142
+ /* 连接已死,close 事件会清理 */
143
+ }
144
+ }
145
+
146
+ function broadcastState() {
147
+ if (!sseClients.size) return;
148
+ let body;
149
+ try {
150
+ body = JSON.stringify(buildState());
151
+ } catch (_) {
152
+ return;
153
+ }
154
+ for (const res of [...sseClients]) pushSse(res, body);
155
+ }
156
+
157
+ /** 状态变化 → 节流合并后推送(400ms 窗口内多次落盘只推一次) */
158
+ function scheduleBroadcast() {
159
+ if (ssePending) return;
160
+ ssePending = true;
161
+ sseThrottleTimer = setTimeout(() => {
162
+ ssePending = false;
163
+ broadcastState();
164
+ }, 400);
165
+ if (sseThrottleTimer && sseThrottleTimer.unref) sseThrottleTimer.unref();
166
+ }
167
+
168
+ function ensureHeartbeat() {
169
+ if (sseHeartbeat) return;
170
+ sseHeartbeat = setInterval(() => {
171
+ for (const res of [...sseClients]) pushSse(res, ': ping'); // 注释行保持连接活跃
172
+ if (!sseClients.size) stopHeartbeat();
173
+ }, 15000);
174
+ if (sseHeartbeat && sseHeartbeat.unref) sseHeartbeat.unref();
175
+ }
176
+
177
+ function stopHeartbeat() {
178
+ if (sseHeartbeat) {
179
+ clearInterval(sseHeartbeat);
180
+ sseHeartbeat = null;
181
+ }
182
+ }
183
+
184
+ /** 插件停用/路由卸载:关闭全部 SSE 长连接(生命周期成本:不留悬挂连接) */
185
+ export function closeAllSse() {
186
+ for (const res of [...sseClients]) {
187
+ try {
188
+ res.end();
189
+ } catch (_) {
190
+ /* ignore */
191
+ }
192
+ }
193
+ sseClients.clear();
194
+ stopHeartbeat();
195
+ if (sseThrottleTimer) {
196
+ clearTimeout(sseThrottleTimer);
197
+ sseThrottleTimer = null;
198
+ ssePending = false;
199
+ }
200
+ unbindChangeBus(); // 退订状态总线:不留「停用后每次落盘仍空转节流定时器」的悬挂副作用
201
+ }
202
+
203
+ /** 订阅任务/配置变化总线(幂等,只绑一次) */
204
+ let sseBusDisposers = [];
205
+ function bindChangeBus() {
206
+ if (sseBound) return;
207
+ sseBound = true;
208
+ sseBusDisposers = [tasks.onChange(scheduleBroadcast), onConfigChange(scheduleBroadcast)];
209
+ }
210
+ function unbindChangeBus() {
211
+ for (const d of sseBusDisposers) {
212
+ try { d(); } catch (_) { /* 幂等退订 */ }
213
+ }
214
+ sseBusDisposers = [];
215
+ sseBound = false;
216
+ }
217
+
218
+ /** GET /iris/api/state/events —— SSE 流:连接即推当前状态,此后状态变化实时推送 */
219
+ function serveSse(res) {
220
+ res.writeHead(200, {
221
+ 'Content-Type': 'text/event-stream; charset=utf-8',
222
+ 'Cache-Control': 'no-cache, no-transform',
223
+ Connection: 'keep-alive',
224
+ 'X-Accel-Buffering': 'no'
225
+ });
226
+ res.write('retry: 3000\n\n'); // 断线 3s 自动重连
227
+ sseClients.add(res);
228
+ bindChangeBus();
229
+ ensureHeartbeat();
230
+ pushSse(res, JSON.stringify(buildState())); // 立即推当前状态
231
+ const onClose = () => {
232
+ sseClients.delete(res);
233
+ if (!sseClients.size) {
234
+ stopHeartbeat();
235
+ if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
236
+ sseThrottleTimer = null;
237
+ ssePending = false;
238
+ unbindChangeBus();
239
+ }
240
+ };
241
+ res.on('close', onClose);
242
+ res.on('error', onClose);
243
+ }
244
+
245
+ /** webServer 前缀路由 handler:GET/HEAD /iris/api/state 和 /iris/api/task/:id,POST /iris/api/actions/:name */
246
+ export function serveApi(req, res, ctx) {
247
+ try {
248
+ const method = String(req.method || 'GET').toUpperCase();
249
+ const urlPath = String(req.url || '').split('?')[0];
250
+
251
+ // POST /iris/api/actions/:name —— GUI 直连触发工具执行
252
+ if (method === 'POST') {
253
+ // 文件选择器 L2:上传本地文件字节 → 存 uploads/ → 返回路径(独立大 body 上限)
254
+ if (urlPath === '/iris/api/upload') {
255
+ return handleUpload(req, res);
256
+ }
257
+ const actionMatch = typeof ctx === 'object' && urlPath.match(/^\/iris\/api\/actions\/([A-Za-z0-9_-]+)\/?$/);
258
+ if (!actionMatch) { return sendJson(res, 405, { error: 'method not allowed' }); }
259
+ return handleAction(ctx, actionMatch[1], req, res);
260
+ }
261
+ // GET/HEAD 原逻辑
262
+ if (method !== 'GET' && method !== 'HEAD') {
263
+ return sendJson(res, 405, { error: 'method not allowed' });
264
+ }
265
+ // SSE 共享状态推送(阶段 4):仅 GET /iris/api/state/events 长连接;HEAD 不适用(无 body)
266
+ if (urlPath === '/iris/api/state/events') {
267
+ if (method === 'HEAD') return sendJson(res, 405, { error: 'method not allowed' });
268
+ return serveSse(res);
269
+ }
270
+ const taskMatch = urlPath.match(/^\/iris\/api\/task\/([^/]+)$/);
271
+ if (taskMatch) {
272
+ return serveTaskDetail(taskMatch[1], method, res);
273
+ }
274
+ if (urlPath !== '/iris/api/state') {
275
+ return sendJson(res, 404, { error: 'not found' });
276
+ }
277
+ const body = JSON.stringify(buildState());
278
+ res.writeHead(200, {
279
+ 'Content-Type': 'application/json; charset=utf-8',
280
+ 'Cache-Control': 'no-store',
281
+ 'X-Content-Type-Options': 'nosniff'
282
+ });
283
+ res.end(method === 'HEAD' ? undefined : body);
284
+ } catch (err) {
285
+ console.error('[iris] /iris/api 异常:', err && err.message);
286
+ sendJson(res, 500, { error: 'iris api error' });
287
+ }
288
+ }
289
+
290
+ function sendJson(res, status, obj) {
291
+ if (res.writableEnded || res.destroyed) return; // 已结束/已销毁的连接不再写(end 后二次 end 防御)
292
+ if (!res.headersSent) res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
293
+ res.end(JSON.stringify(obj));
294
+ }
295
+
296
+ /** POST /iris/api/actions/:name:读取 JSON body 调 runAction 返回结果 */
297
+ const MAX_BODY_BYTES = 1e6; // 1MB 上限(防滥用)
298
+ function handleAction(ctx, name, req, res) {
299
+ const chunks = [];
300
+ const controller = new AbortController();
301
+ let completed = false;
302
+ const finish = (status, body) => {
303
+ if (completed) return;
304
+ completed = true;
305
+ sendJson(res, status, body);
306
+ };
307
+ const disconnect = () => { if (!completed) controller.abort(); };
308
+ if (typeof req.on === 'function') req.on('aborted', disconnect);
309
+ if (typeof res.on === 'function') res.on('close', disconnect);
310
+ let totalBytes = 0; // 注意:必须按字节计——body 数组的 length 是块数不是字节数(旧代码的坑)
311
+ let overflow = false;
312
+ req.on('data', (c) => {
313
+ if (overflow) return;
314
+ totalBytes += c.length;
315
+ if (totalBytes > MAX_BODY_BYTES) {
316
+ overflow = true;
317
+ chunks.length = 0; // 丢弃已收块,不再占内存
318
+ return;
319
+ }
320
+ chunks.push(c);
321
+ });
322
+ req.on('end', async () => {
323
+ if (overflow) return finish(413, { error: 'request body too large (limit 1MB)' });
324
+ try {
325
+ const raw = Buffer.concat(chunks).toString('utf8');
326
+ const args = raw ? JSON.parse(raw) : {};
327
+ const result = await runAction(ctx, name, args, { signal: controller.signal });
328
+ finish(200, result);
329
+ } catch (err) {
330
+ finish(controller.signal.aborted ? 499 : 400, { error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err) });
331
+ }
332
+ });
333
+ req.on('error', () => finish(400, { error: 'bad request' }));
334
+ }
335
+
336
+ /* ---------- 文件选择器 L2:本地文件上传(阶段 10) ---------- */
337
+ export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
338
+ export const UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
339
+
340
+ function uploadsDir() {
341
+ return path.join(irisHome(), 'uploads');
342
+ }
343
+
344
+ /** uploads/ 是输入临时副本:启动及每次上传前清理超过 7 天的普通文件。 */
345
+ export function purgeStaleUploads({ now = Date.now(), maxAgeMs = UPLOAD_TTL_MS } = {}) {
346
+ const dir = uploadsDir();
347
+ if (!fs.existsSync(dir)) return { deleted: 0, bytes: 0 };
348
+ let deleted = 0;
349
+ let bytes = 0;
350
+ for (const name of fs.readdirSync(dir)) {
351
+ const file = path.join(dir, name);
352
+ try {
353
+ const st = fs.statSync(file);
354
+ if (!st.isFile() || now - st.mtimeMs <= maxAgeMs) continue;
355
+ fs.rmSync(file, { force: true });
356
+ deleted++;
357
+ bytes += st.size;
358
+ } catch (_) { /* 单文件失败不阻断宿主启动/上传 */ }
359
+ }
360
+ return { deleted, bytes };
361
+ }
362
+
363
+ /** 只保留安全文件名字符,去路径分隔/穿越;空则回退 upload。 */
364
+ function safeName(name) {
365
+ const base = path.basename(String(name || '')).replace(/[^A-Za-z0-9_.一-鿿-]/g, '_').slice(0, 120);
366
+ return base && base !== '.' && base !== '..' ? base : 'upload';
367
+ }
368
+
369
+ /** 有界流式落盘:不再把最多 64MB 的 chunks 与 Buffer.concat 同时留在内存。 */
370
+ function handleUpload(req, res) {
371
+ const q = new URL(req.url || '', 'http://x').searchParams;
372
+ const name = safeName(q.get('name') || 'upload');
373
+ fs.mkdirSync(uploadsDir(), { recursive: true });
374
+ purgeStaleUploads();
375
+
376
+ const stem = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
377
+ const abs = path.join(uploadsDir(), stem + '-' + name);
378
+ const tmp = abs + '.part';
379
+ const out = fs.createWriteStream(tmp, { mode: 0o600, flags: 'wx' });
380
+ let totalBytes = 0;
381
+ let settled = false;
382
+
383
+ const discard = () => {
384
+ try { out.destroy(); } catch (_) { /* ignore */ }
385
+ try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
386
+ };
387
+ const finish = (status, body) => {
388
+ if (settled) return;
389
+ settled = true;
390
+ if (status !== 200) discard();
391
+ sendJson(res, status, body);
392
+ };
393
+
394
+ out.on('error', (err) => finish(500, { error: 'upload failed: ' + String(err && err.message) }));
395
+ req.on('data', (chunk) => {
396
+ if (settled) return;
397
+ totalBytes += chunk.length;
398
+ if (totalBytes > MAX_UPLOAD_BYTES) {
399
+ finish(413, { error: 'file too large (limit 64MB)' });
400
+ if (typeof req.resume === 'function') req.resume();
401
+ return;
402
+ }
403
+ if (!out.write(chunk) && typeof req.pause === 'function') {
404
+ req.pause();
405
+ out.once('drain', () => { if (!settled && typeof req.resume === 'function') req.resume(); });
406
+ }
407
+ });
408
+ req.on('end', () => {
409
+ if (settled) return;
410
+ if (!totalBytes) return finish(400, { error: 'empty upload' });
411
+ out.end(() => {
412
+ if (settled) return;
413
+ try {
414
+ fs.renameSync(tmp, abs);
415
+ finish(200, { ok: true, path: abs, name, expiresInMs: UPLOAD_TTL_MS });
416
+ } catch (err) {
417
+ finish(500, { error: 'upload failed: ' + String(err && err.message) });
418
+ }
419
+ });
420
+ });
421
+ req.on('aborted', () => finish(499, { error: 'upload canceled' }));
422
+ req.on('error', () => finish(400, { error: 'bad upload' }));
423
+ if (typeof res.on === 'function') {
424
+ res.on('close', () => { if (!settled) finish(499, { error: 'upload canceled' }); });
425
+ }
426
+ }
427
+
428
+ /** GET /iris/api/task/:id —— 返回完整任务详情(含完整 prompt、error、remoteTaskId、attachments) */
429
+ function serveTaskDetail(taskId, method, res) {
430
+ const t = tasks.get(taskId);
431
+ if (!t) {
432
+ res.writeHead(404, { 'Content-Type': 'application/json' });
433
+ res.end('{"error":"task not found"}');
434
+ return;
435
+ }
436
+ const elapsedMs =
437
+ t.elapsedMs ||
438
+ (t.finishedAt && t.createdAt ? new Date(t.finishedAt).getTime() - new Date(t.createdAt).getTime() : undefined);
439
+ const body = JSON.stringify({
440
+ id: t.id,
441
+ cap: t.cap || '',
442
+ status: t.status || '',
443
+ progress: t.progress || '',
444
+ model: t.model || '',
445
+ providerName: t.providerName || '',
446
+ prompt: t.prompt || '',
447
+ createdAt: t.createdAt || '',
448
+ finishedAt: t.finishedAt || '',
449
+ elapsedMs: elapsedMs || undefined,
450
+ error: t.error || '',
451
+ mode: t.mode || '',
452
+ remoteTaskId: t.remoteTaskId || '',
453
+ files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
454
+ media: mediaOf(t),
455
+ attachments: Array.isArray(t.attachments) ? t.attachments.map((a) => ({
456
+ attachmentId: a.attachmentId,
457
+ file: a.file || '',
458
+ mediaType: a.mediaType || ''
459
+ })) : []
460
+ });
461
+ res.writeHead(200, {
462
+ 'Content-Type': 'application/json; charset=utf-8',
463
+ 'Cache-Control': 'no-store',
464
+ 'X-Content-Type-Options': 'nosniff'
465
+ });
466
+ res.end(method === 'HEAD' ? undefined : body);
467
+ }
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris Provider 能力系统(阶段 1)。
4
+ *
5
+ * 目标(对齐 PLAN 阶段 1 与 RESEARCH 的 "test it, don't guess it"):
6
+ * - 能力常量:image-gen / video-gen / tts / transcribe / vision
7
+ * - 严格选择:不具备某能力的 Provider 不得被兜底选中
8
+ * - 有序 failover:同一能力按 providers.json 声明顺序返回,调用方按序尝试
9
+ * - 旧配置迁移:老 provider(无 capabilities 数组)按模型字段推断能力
10
+ *
11
+ * 本模块是纯函数,不读写磁盘;只吃 provider 记录、吐能力判断。
12
+ */
13
+ export const CAPABILITIES = Object.freeze({
14
+ IMAGE: 'image-gen',
15
+ VIDEO: 'video-gen',
16
+ TTS: 'tts',
17
+ TRANSCRIBE: 'transcribe',
18
+ VISION: 'vision'
19
+ });
20
+
21
+ /** 已知能力集合(用于过滤非法能力名) */
22
+ const KNOWN = new Set(Object.values(CAPABILITIES));
23
+
24
+ /** 旧配置迁移:能力 → 推断来源模型字段 */
25
+ const INFER_FIELD = {
26
+ [CAPABILITIES.IMAGE]: 'imageModel',
27
+ [CAPABILITIES.VIDEO]: 'videoModel',
28
+ [CAPABILITIES.TTS]: 'ttsModel',
29
+ [CAPABILITIES.TRANSCRIBE]: 'transcribeModel',
30
+ [CAPABILITIES.VISION]: 'visionModel'
31
+ };
32
+
33
+ export function isKnownCapability(capability) {
34
+ return KNOWN.has(capability);
35
+ }
36
+
37
+ /**
38
+ * 一个 provider 的能力列表(去重、只留已知能力)。
39
+ * 迁移规则(旧配置):
40
+ * ① capabilities 显式声明 → 权威(严格使用,不额外推断);
41
+ * ② 否则按模型字段推断(imageModel→image-gen 等);
42
+ * ③ 仍为空且是 DashScope 端点 → 全部媒体能力
43
+ * (百炼单一 API 服务 t2i/t2v/tts/compatible-vision,裸账号常见于迁移期)。
44
+ * @param {object} p provider 记录
45
+ * @returns {string[]}
46
+ */
47
+ export function capabilitiesOf(p) {
48
+ const declared = Array.isArray(p && p.capabilities) ? p.capabilities : [];
49
+ if (declared.some((c) => isKnownCapability(c))) {
50
+ return [...new Set(declared)].filter(isKnownCapability);
51
+ }
52
+ // 旧配置迁移:按模型字段推断(有该能力模型才认为具备该能力)
53
+ const inferred = [];
54
+ for (const [cap, field] of Object.entries(INFER_FIELD)) {
55
+ if (p && p[field]) inferred.push(cap);
56
+ }
57
+ if (inferred.length) return inferred;
58
+ // 裸 DashScope 账号兜底迁移
59
+ if (p && /dashscope/i.test(String(p.baseUrl || ''))) {
60
+ return Object.values(CAPABILITIES);
61
+ }
62
+ return [];
63
+ }
64
+
65
+ /**
66
+ * 声明(或推断)了某能力的 provider 有序列表。
67
+ * @param {Array} providers 已启用的 provider 列表
68
+ * @param {string} capability 能力常量
69
+ * @returns {Array}
70
+ */
71
+ export function providersWith(providers, capability) {
72
+ return (Array.isArray(providers) ? providers : []).filter((p) => capabilitiesOf(p).includes(capability));
73
+ }
74
+
75
+ /**
76
+ * 严格选择:只挑声明了该能力的 provider,无则 null(不被兜底选中)。
77
+ * @param {Array} providers
78
+ * @param {string} capability
79
+ * @returns {object|null}
80
+ */
81
+ export function pickFor(providers, capability) {
82
+ return providersWith(providers, capability)[0] || null;
83
+ }
84
+
85
+ /**
86
+ * 有序 failover 辅助:依次尝试每个 provider 的能力函数,失败继续下一个。
87
+ * @param {Array} providers 有序 provider 列表
88
+ * @param {Function} run async (provider, index) => T
89
+ * @returns {{value: T|null, provider: object|null, errors: Array}}
90
+ */
91
+ export async function tryOrdered(providers, run) {
92
+ const errors = [];
93
+ for (let i = 0; i < providers.length; i++) {
94
+ const p = providers[i];
95
+ try {
96
+ const value = await run(p, i);
97
+ if (value !== undefined && value !== null) return { value, provider: p, errors };
98
+ } catch (err) {
99
+ errors.push({ providerId: p && p.id, message: String((err && err.message) || err) });
100
+ }
101
+ }
102
+ return { value: null, provider: null, errors };
103
+ }
@@ -0,0 +1,5 @@
1
+ declare const _default: {
2
+ inject: string[];
3
+ apply(ctx: any): void;
4
+ };
5
+ export = _default;