dsh-armory 2.3.0 → 2.3.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/README.en.md +21 -15
- package/README.md +24 -14
- package/docs/installation.md +0 -2
- package/lib/client.js +2323 -629
- package/lib/community-meta.js +222 -0
- package/lib/expert-market.js +119 -23
- package/lib/index.js +218 -91
- package/lib/loadout.js +178 -0
- package/lib/mcp-config.js +0 -171
- package/lib/mcp-oauth.js +2 -4
- package/lib/npm-downloads.js +262 -0
- package/lib/suites-registry.js +10 -4
- package/lib/typert.js +13 -10
- package/lib/wb-awesome-market.js +706 -214
- package/lib/wb-install-state.js +10 -4
- package/lib/wb-market.js +99 -27
- package/lib/wb-registry.js +32 -31
- package/lib/whats-new-en.js +21 -0
- package/lib/whats-new.js +24 -0
- package/package.json +2 -2
- package/docs/mcp-market-design.md +0 -617
- package/lib/mcp-catalog-en.js +0 -244
- package/lib/mcp-catalog.js +0 -806
- package/lib/wb-plugin-market.js +0 -291
package/lib/wb-awesome-market.js
CHANGED
|
@@ -1,26 +1,41 @@
|
|
|
1
1
|
// awesome-dsh-plugins 市场数据层(T01)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 与连接器市场(lib/wb-market.js 的 zip 管线)完全解耦:
|
|
4
4
|
// awesome 源是单文件裸 JSON(~13.4MB,全量 5 千余条,其中 unverified/broken 占
|
|
5
5
|
// 绝大多数),本模块只做「拉取链 → 瘦身过滤 → 原子缓存」三件事,纯数据层,
|
|
6
6
|
// 不做安装、不做 UI 投影。纪律(与 wb-registry.js 同款):
|
|
7
7
|
// - 源 URL 只活在本文件常量 AWESOME_SOURCES 里(裁定 1:registry 不感知 awesome,
|
|
8
8
|
// 禁止出现第二处硬编码真相)。
|
|
9
9
|
// - 缓存目录 <root>/awesome-market/:slim.json(瘦身影射数组)+ meta.json
|
|
10
|
-
// ({etag, fetchedAt, updated, counts}),落盘一律
|
|
10
|
+
// ({etag, fetchedAt, updated, counts, source, resolvedSha}),落盘一律
|
|
11
|
+
// staging + rm + rename 原子写。
|
|
11
12
|
// - 测试可通过 options.rootDir 或 DSH_ARMORY_HOME 把根目录指到临时目录,
|
|
12
13
|
// 绝不碰真实 ~/.dsh。
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
//
|
|
15
|
+
// 新鲜度(为什么要有 resolvedSha):jsdelivr 对 @main 分支文件有 12h CDN 边缘缓存
|
|
16
|
+
// (s-maxage=43200),旧口径「@main + ETag 条件请求」会被陈旧边缘 304 骗过——上游
|
|
17
|
+
// 仓库已更新,但边缘还握着旧副本且其 ETag 恰与本地一致,市场数据最长滞后 12 小时
|
|
18
|
+
// (2026-10 实测复现)。现在首选经 api.github.com 把 main 解析到最新提交 SHA,再从
|
|
19
|
+
// 各镜像的 @<sha> 不可变地址取数:不可变地址 CDN 永不陈旧,SHA 相同即内容相同、
|
|
20
|
+
// 连下载都省掉;API 不可达时回落旧口径(陈旧窗口回归 12h 上限),可用性不降。
|
|
21
|
+
//
|
|
22
|
+
// 第二社区源(聚合):dsh-market 插件展示的 6.7k 目录来自另一个独立注册表
|
|
23
|
+
// awesome-dsh-plugin.com(CC0),其 npm 可装子集与本源并不重合(实测 npm 并集
|
|
24
|
+
// ~7.2k)。这里把它的 npm 包 dsh-plugin-catalog 作为第二源接入:只取 npm 非空
|
|
25
|
+
// 条目(git-only 不进面板),适配成同一瘦身影射后独立落盘 second.json,服务时
|
|
26
|
+
// 按 npm 名与主源去重合并(主源优先)。取数走「registry 解析 latest 版本号 →
|
|
27
|
+
// jsDelivr @<版本> 不可变地址」双镜像链,机制与主源 SHA 同构。
|
|
28
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
29
|
+
import { homedir } from 'node:os';
|
|
30
|
+
import { join } from 'node:path';
|
|
16
31
|
|
|
17
32
|
/** 市场标识:client 接收 catalog 响应后按 res.marketplace 给条目统一贴标。 */
|
|
18
|
-
export const AWESOME_MARKETPLACE_ID =
|
|
33
|
+
export const AWESOME_MARKETPLACE_ID = 'awesome-dsh-plugins';
|
|
19
34
|
|
|
20
35
|
// 双 CDN 拉取链(jsdelivr 主源 → fastly 备源,按序尝试,单源失败切下一源)。
|
|
21
36
|
const AWESOME_SOURCES = [
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
'https://cdn.jsdelivr.net/gh/dshworks/awesome-dsh-plugins@main/data/plugins.json',
|
|
38
|
+
'https://fastly.jsdelivr.net/gh/dshworks/awesome-dsh-plugins@main/data/plugins.json',
|
|
24
39
|
];
|
|
25
40
|
|
|
26
41
|
/**
|
|
@@ -31,9 +46,30 @@ const AWESOME_SOURCES = [
|
|
|
31
46
|
*/
|
|
32
47
|
export const SLIM_MAX_BYTES = 3 * 1024 * 1024;
|
|
33
48
|
|
|
34
|
-
const USER_AGENT =
|
|
49
|
+
const USER_AGENT = '@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)';
|
|
35
50
|
const DOWNLOAD_TIMEOUT_MS = 120_000; // 13.4MB 全量下载的超时余量
|
|
36
51
|
const PROBE_TIMEOUT_MS = 30_000; // HEAD 条件探测轻量快速
|
|
52
|
+
const GITHUB_API_TIMEOUT_MS = 20_000; // 提交解析是小 JSON,短超时快速失败回落
|
|
53
|
+
/** 提交 SHA 的防御性形态校验(7-40 位十六进制),杜绝把垃圾拼进 URL。 */
|
|
54
|
+
const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
|
|
55
|
+
/** SHA 解析备忘 TTL:60s 内同根的 catalog/探测共享一次解析结果,对 GitHub API 客气。 */
|
|
56
|
+
const SHA_MEMO_TTL_MS = 60_000;
|
|
57
|
+
|
|
58
|
+
// ── 第二社区源(awesome-dsh-plugin.com 聚合目录,CC0)──────────────────────
|
|
59
|
+
// 另一个独立注册表(github.com/awesome-dsh-plugin/awesome-dsh-plugin,6.7k+ 条,
|
|
60
|
+
// npm 与 git 安装混收)。这里只取其 **npm 非空** 子集与主源并集(面板口径始终是
|
|
61
|
+
// 「可 npm 安装」;服务时按 npm 名去重、主源优先,git-only 条目不进面板)。
|
|
62
|
+
// 数据发布为 npm 包 dsh-plugin-catalog,随构建逐日出版本号。取数与主源 SHA 机制
|
|
63
|
+
// 同构:先向 npm registry 解析 latest 版本号(小 JSON),再从 jsDelivr 双镜像按
|
|
64
|
+
// @<版本> 取文件——版本锁定地址 CDN 标记 immutable(s-maxage=1 年),永不陈旧;
|
|
65
|
+
// 解析失败回落无版本地址 + If-None-Match(CDN 对 npm latest 同样最长缓存 12h,
|
|
66
|
+
// 可用性不降)。
|
|
67
|
+
const SECOND_CATALOG_PKG = 'dsh-plugin-catalog';
|
|
68
|
+
const SECOND_CATALOG_FILE = 'plugins.json';
|
|
69
|
+
const SECOND_VERSION_TIMEOUT_MS = 20_000;
|
|
70
|
+
const SECOND_MIRRORS = ['https://cdn.jsdelivr.net', 'https://fastly.jsdelivr.net'];
|
|
71
|
+
/** npm 版本号防御性形态校验(semver 字符集),杜绝把垃圾拼进 URL。 */
|
|
72
|
+
const SECOND_VERSION_PATTERN = /^[0-9A-Za-z.+-]+$/;
|
|
37
73
|
|
|
38
74
|
/**
|
|
39
75
|
* 首屏提速(stale-while-revalidate):磁盘/内存有货就立刻返回,网络再验证挪到后台。
|
|
@@ -45,68 +81,74 @@ const REVALIDATE_BACKOFF_MS = 60_000;
|
|
|
45
81
|
|
|
46
82
|
/** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory(与 wb-registry.js 同款)。 */
|
|
47
83
|
function awesomeRootDir(options = {}) {
|
|
48
|
-
return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(),
|
|
84
|
+
return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), '.dsh', 'armory');
|
|
49
85
|
}
|
|
50
86
|
|
|
51
87
|
/** 缓存子目录:<root>/awesome-market。 */
|
|
52
88
|
function awesomeMarketDir(options = {}) {
|
|
53
|
-
return join(awesomeRootDir(options),
|
|
89
|
+
return join(awesomeRootDir(options), 'awesome-market');
|
|
54
90
|
}
|
|
55
91
|
|
|
56
92
|
function slimPathOf(options = {}) {
|
|
57
|
-
return join(awesomeMarketDir(options),
|
|
93
|
+
return join(awesomeMarketDir(options), 'slim.json');
|
|
58
94
|
}
|
|
59
95
|
|
|
60
96
|
function metaPathOf(options = {}) {
|
|
61
|
-
return join(awesomeMarketDir(options),
|
|
97
|
+
return join(awesomeMarketDir(options), 'meta.json');
|
|
62
98
|
}
|
|
63
99
|
|
|
64
100
|
/** 读 JSON 文件;不存在/损坏一律返回 null(调用方决定回落语义),永不抛错。 */
|
|
65
101
|
async function readJsonAt(path) {
|
|
66
102
|
try {
|
|
67
|
-
return JSON.parse(await readFile(path,
|
|
103
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
68
104
|
} catch {
|
|
69
105
|
return null;
|
|
70
106
|
}
|
|
71
107
|
}
|
|
72
108
|
|
|
73
|
-
/**
|
|
74
|
-
*
|
|
109
|
+
/** 唯一临时名 + rename 原子写(wb-registry.js writeAtomic 同款):Windows 的 rename
|
|
110
|
+
* 覆盖已存在文件,无需先 rm——固定 `<file>.staging` 在并发写(后台 revalidate 与
|
|
111
|
+
* refresh 强刷并行)时会互相消费对方的 staging,甚至把刚提交的正本删掉。文本一律 UTF-8。 */
|
|
75
112
|
async function writeAtomic(path, text) {
|
|
76
|
-
const cut = Math.max(path.lastIndexOf(
|
|
113
|
+
const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
|
77
114
|
const dir = cut > 0 ? path.slice(0, cut) : path;
|
|
78
115
|
await mkdir(dir, { recursive: true });
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
116
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
117
|
+
try {
|
|
118
|
+
await writeFile(tmp, text, 'utf8');
|
|
119
|
+
await rename(tmp, path);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
82
124
|
}
|
|
83
125
|
|
|
84
126
|
// ── 瘦身(纯函数)──────────────────────────────────────────────────────────
|
|
85
127
|
|
|
86
128
|
/** npm 字段防御提取:非空字符串直取;{name} 形态取 npm.name;其余出局(返回 "")。 */
|
|
87
129
|
function extractNpm(value) {
|
|
88
|
-
if (typeof value ===
|
|
130
|
+
if (typeof value === 'string') {
|
|
89
131
|
const trimmed = value.trim();
|
|
90
|
-
return trimmed ||
|
|
132
|
+
return trimmed || '';
|
|
91
133
|
}
|
|
92
|
-
if (value && typeof value ===
|
|
93
|
-
if (typeof value.name ===
|
|
134
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
135
|
+
if (typeof value.name === 'string') {
|
|
94
136
|
const trimmed = value.name.trim();
|
|
95
|
-
return trimmed ||
|
|
137
|
+
return trimmed || '';
|
|
96
138
|
}
|
|
97
139
|
}
|
|
98
|
-
return
|
|
140
|
+
return '';
|
|
99
141
|
}
|
|
100
142
|
|
|
101
143
|
/** description 防御截断:非字符串回落 "";字符串截断 ≤120 字符(降级旋钮见 SLIM_MAX_BYTES)。 */
|
|
102
144
|
function truncateText(value, maxChars = 120) {
|
|
103
|
-
return typeof value ===
|
|
145
|
+
return typeof value === 'string' ? value.slice(0, maxChars) : '';
|
|
104
146
|
}
|
|
105
147
|
|
|
106
148
|
/** tags 防御归一:非数组 → [];数组只留字符串且最多 2 个。 */
|
|
107
149
|
function normalizeTags(value) {
|
|
108
150
|
if (!Array.isArray(value)) return [];
|
|
109
|
-
return value.filter((tag) => typeof tag ===
|
|
151
|
+
return value.filter((tag) => typeof tag === 'string').slice(0, 2);
|
|
110
152
|
}
|
|
111
153
|
|
|
112
154
|
/**
|
|
@@ -123,25 +165,25 @@ export function slimEntries(rawArray) {
|
|
|
123
165
|
if (!Array.isArray(rawArray)) return [];
|
|
124
166
|
const slim = [];
|
|
125
167
|
for (const raw of rawArray) {
|
|
126
|
-
if (!raw || typeof raw !==
|
|
127
|
-
if (raw.status !==
|
|
168
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
|
169
|
+
if (raw.status !== 'verified') continue;
|
|
128
170
|
const npm = extractNpm(raw.npm);
|
|
129
171
|
if (!npm) continue;
|
|
130
|
-
const name = typeof raw.name ===
|
|
172
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
131
173
|
if (!name) continue;
|
|
132
|
-
const category = typeof raw.category ===
|
|
174
|
+
const category = typeof raw.category === 'string' && raw.category.trim() ? raw.category.trim() : 'plugin';
|
|
133
175
|
slim.push({
|
|
134
176
|
name,
|
|
135
|
-
repo: typeof raw.repo ===
|
|
177
|
+
repo: typeof raw.repo === 'string' ? raw.repo : '',
|
|
136
178
|
npm,
|
|
137
179
|
description: truncateText(raw.description, 120),
|
|
138
180
|
category,
|
|
139
|
-
status:
|
|
181
|
+
status: 'verified',
|
|
140
182
|
tags: normalizeTags(raw.tags),
|
|
141
|
-
stars: typeof raw.stars ===
|
|
183
|
+
stars: typeof raw.stars === 'number' && Number.isFinite(raw.stars) ? raw.stars : 0,
|
|
142
184
|
official: Boolean(raw.official),
|
|
143
|
-
verifiedAgainst: typeof raw.verifiedAgainst ===
|
|
144
|
-
lastVerified: typeof raw.lastVerified ===
|
|
185
|
+
verifiedAgainst: typeof raw.verifiedAgainst === 'string' ? raw.verifiedAgainst : '',
|
|
186
|
+
lastVerified: typeof raw.lastVerified === 'string' ? raw.lastVerified : '',
|
|
145
187
|
});
|
|
146
188
|
}
|
|
147
189
|
return slim;
|
|
@@ -156,17 +198,179 @@ function countsOf(entries) {
|
|
|
156
198
|
return { total: entries.length, byCategory };
|
|
157
199
|
}
|
|
158
200
|
|
|
201
|
+
/**
|
|
202
|
+
* 第二源目录条目 → 瘦身影射形态(纯函数)。只保留 **npm 非空** 条目——合并口径
|
|
203
|
+
* 是「可 npm 安装」,git-only 条目不进面板。字段映射:description 中英双语取
|
|
204
|
+
* zh 优先(该源面向中文社区);repo 由 url 归一成 owner/repo;category 用该源
|
|
205
|
+
* 自有的功能分类 id(面板分类筛选自然出现两套体系);该源不携带 dsh 版本验证
|
|
206
|
+
* 元数据,verifiedAgainst/lastVerified 置空、status 恒为 verified(其 curated 口径)。
|
|
207
|
+
*/
|
|
208
|
+
export function adaptSecondEntries(rawArray) {
|
|
209
|
+
if (!Array.isArray(rawArray)) return [];
|
|
210
|
+
const slim = [];
|
|
211
|
+
for (const raw of rawArray) {
|
|
212
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
|
213
|
+
const npm = extractNpm(raw.npm);
|
|
214
|
+
if (!npm) continue;
|
|
215
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
216
|
+
if (!name) continue;
|
|
217
|
+
const desc = raw.description;
|
|
218
|
+
const text =
|
|
219
|
+
typeof desc === 'string'
|
|
220
|
+
? desc
|
|
221
|
+
: desc && typeof desc === 'object'
|
|
222
|
+
? typeof desc.zh === 'string' && desc.zh
|
|
223
|
+
? desc.zh
|
|
224
|
+
: typeof desc.en === 'string'
|
|
225
|
+
? desc.en
|
|
226
|
+
: ''
|
|
227
|
+
: '';
|
|
228
|
+
slim.push({
|
|
229
|
+
name,
|
|
230
|
+
repo: parseGithubRepo(raw.url),
|
|
231
|
+
npm,
|
|
232
|
+
description: truncateText(text, 120),
|
|
233
|
+
category: typeof raw.category === 'string' && raw.category.trim() ? raw.category.trim() : 'plugin',
|
|
234
|
+
status: 'verified',
|
|
235
|
+
tags: [],
|
|
236
|
+
stars: typeof raw.stars === 'number' && Number.isFinite(raw.stars) ? raw.stars : 0,
|
|
237
|
+
official: false,
|
|
238
|
+
verifiedAgainst: '',
|
|
239
|
+
lastVerified: '',
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return slim;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 服务时合并(纯函数):主源瘦身条目 + 第二源中主源没有的 npm 名。主源优先——
|
|
247
|
+
* 同一 npm 包在面板只出现一次,且主源后续补录的条目会自然顶掉第二源副本,
|
|
248
|
+
* 无需重拉第二源。secondEntries 为空/损坏时原样返回主源。
|
|
249
|
+
*/
|
|
250
|
+
export function mergeSecondEntries(primaryEntries, secondEntries) {
|
|
251
|
+
const primary = Array.isArray(primaryEntries) ? primaryEntries : [];
|
|
252
|
+
if (!Array.isArray(secondEntries) || secondEntries.length === 0) return primary;
|
|
253
|
+
const seen = new Set(primary.map((entry) => entry.npm));
|
|
254
|
+
const extra = secondEntries.filter((entry) => entry && entry.npm && !seen.has(entry.npm));
|
|
255
|
+
return [...primary, ...extra];
|
|
256
|
+
}
|
|
257
|
+
|
|
159
258
|
// ── 拉取链与缓存落盘 ────────────────────────────────────────────────────────
|
|
160
259
|
|
|
161
260
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
|
|
261
|
+
* 从 gh 源 URL 解析 <owner>/<repo>@<branch>/<path> 形态;不符合返回 null。
|
|
262
|
+
* 形态:https://<镜像域名>/gh/<owner>/<repo>@<branch>/<path>。
|
|
263
|
+
*/
|
|
264
|
+
function parseGhSource(sourceUrl) {
|
|
265
|
+
const match = /^https?:\/\/[^/\s]+\/gh\/([^/\s@]+)\/([^/\s@]+)@([^/\s]+)\/(.+)$/.exec(String(sourceUrl || ''));
|
|
266
|
+
return match ? { owner: match[1], repo: match[2], branch: match[3], filePath: match[4] } : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 把 gh 源 URL 的分支段替换成提交 SHA(保留各自镜像域名)。替换没发生(形态
|
|
271
|
+
* 不符 / SHA 非法)返回空串,调用方跳过该源——绝不把未校验的字符串拼进 URL。
|
|
272
|
+
*/
|
|
273
|
+
function pinSourceToSha(sourceUrl, sha) {
|
|
274
|
+
if (!SHA_PATTERN.test(String(sha || ''))) return '';
|
|
275
|
+
const gh = parseGhSource(sourceUrl);
|
|
276
|
+
if (!gh) return '';
|
|
277
|
+
const pinned = String(sourceUrl).replace(`/${gh.owner}/${gh.repo}@${gh.branch}/`, `/${gh.owner}/${gh.repo}@${sha}/`);
|
|
278
|
+
return pinned !== sourceUrl ? pinned : '';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 解析默认分支最新提交 SHA(GitHub commits API,小 JSON)。失败(网络/限流/畸形)
|
|
283
|
+
* 返回 "",调用方回落 @main 链——绝不让解析失败拖垮目录加载。
|
|
284
|
+
* 备忘按缓存根分键(与 memSlim 同款):60s 内同根调用共享一次解析,测试各用
|
|
285
|
+
* 各的临时目录互不串味。
|
|
165
286
|
*/
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
287
|
+
let memSha = null; // { rootKey, sha, at }
|
|
288
|
+
async function resolveBranchSha(options = {}) {
|
|
289
|
+
const rootKey = memKeyOf(options);
|
|
290
|
+
if (memSha && memSha.rootKey === rootKey && Date.now() - memSha.at < SHA_MEMO_TTL_MS) {
|
|
291
|
+
return memSha.sha;
|
|
292
|
+
}
|
|
293
|
+
const gh = parseGhSource(AWESOME_SOURCES[0]);
|
|
294
|
+
let sha = '';
|
|
295
|
+
if (gh) {
|
|
296
|
+
try {
|
|
297
|
+
const response = await fetch(`https://api.github.com/repos/${gh.owner}/${gh.repo}/commits/${gh.branch}`, {
|
|
298
|
+
headers: { 'user-agent': USER_AGENT, accept: 'application/vnd.github+json' },
|
|
299
|
+
signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS),
|
|
300
|
+
});
|
|
301
|
+
if (response.ok) {
|
|
302
|
+
const doc = await response.json();
|
|
303
|
+
if (doc && typeof doc.sha === 'string' && SHA_PATTERN.test(doc.sha)) sha = doc.sha;
|
|
304
|
+
}
|
|
305
|
+
} catch {
|
|
306
|
+
/* API 不可达:sha 保持 "",走 @main 回落链 */
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
memSha = { rootKey, sha, at: Date.now() };
|
|
310
|
+
return sha;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 拉到的响应体 → 校验过形状的源数据;缺 plugins 数组抛错(由调用方折进 lastError)。 */
|
|
314
|
+
function parsePluginsPayload(buffer) {
|
|
315
|
+
const data = JSON.parse(Buffer.from(buffer).toString('utf8'));
|
|
316
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.plugins)) {
|
|
317
|
+
throw new Error('响应缺少 plugins 数组');
|
|
318
|
+
}
|
|
319
|
+
return data;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 拉取链(两级):
|
|
324
|
+
* ① 首选 SHA 口径:解析分支最新提交 → 与 meta.resolvedSha 相同即内容未变
|
|
325
|
+
* (不可变地址的性质),直接 notModified,连 CDN 都不用碰;不同则按
|
|
326
|
+
* AWESOME_SOURCES 顺序拉 @<sha> 不可变地址(CDN 永不陈旧,根因修复点)。
|
|
327
|
+
* ② 回落 @main 口径(API 不可达 / pinned 链全挂时):条件 GET 行为原样保留,
|
|
328
|
+
* 单源失败(网络异常 / 非 2xx / 响应体畸形)→ 试下一源;304 且有 etag →
|
|
329
|
+
* { ok, notModified }。
|
|
330
|
+
* 返回 { ok, notModified } | { ok, data, etag, source, sha } | { ok: false, error }。
|
|
331
|
+
* 下载全程带 120s 超时。注意:② 成功时不含 sha 字段(@main 无法知道对应哪个提交,
|
|
332
|
+
* 落盘 resolvedSha 置空,下轮再验证重新解析)。
|
|
333
|
+
*/
|
|
334
|
+
async function fetchChain(localMeta = {}, options = {}) {
|
|
335
|
+
const etag = localMeta && typeof localMeta.etag === 'string' ? localMeta.etag : '';
|
|
336
|
+
const localSha = localMeta && typeof localMeta.resolvedSha === 'string' ? localMeta.resolvedSha : '';
|
|
337
|
+
let lastError = '';
|
|
338
|
+
|
|
339
|
+
// ① SHA 口径:不可变地址,新鲜且可缓存终身
|
|
340
|
+
const sha = await resolveBranchSha(options);
|
|
341
|
+
if (sha) {
|
|
342
|
+
if (sha === localSha) return { ok: true, notModified: true };
|
|
343
|
+
const pinnedErrors = [];
|
|
344
|
+
for (const sourceUrl of AWESOME_SOURCES) {
|
|
345
|
+
const pinnedUrl = pinSourceToSha(sourceUrl, sha);
|
|
346
|
+
if (!pinnedUrl) continue;
|
|
347
|
+
let response;
|
|
348
|
+
try {
|
|
349
|
+
response = await fetch(pinnedUrl, {
|
|
350
|
+
headers: { 'user-agent': USER_AGENT },
|
|
351
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
352
|
+
});
|
|
353
|
+
} catch (error) {
|
|
354
|
+
pinnedErrors.push(`${pinnedUrl} → ${error.message}`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (!response.ok) {
|
|
358
|
+
pinnedErrors.push(`${pinnedUrl} → HTTP ${response.status}`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
const data = parsePluginsPayload(await response.arrayBuffer());
|
|
363
|
+
return { ok: true, data, etag: response.headers.get('etag') || '', source: pinnedUrl, sha };
|
|
364
|
+
} catch (error) {
|
|
365
|
+
pinnedErrors.push(`${pinnedUrl} → ${error.message}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
lastError = pinnedErrors.join('; ');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ② @main 回落口径:条件 GET(现状行为)
|
|
372
|
+
const headers = { 'user-agent': USER_AGENT };
|
|
373
|
+
if (etag) headers['if-none-match'] = etag;
|
|
170
374
|
for (const sourceUrl of AWESOME_SOURCES) {
|
|
171
375
|
let response;
|
|
172
376
|
try {
|
|
@@ -181,12 +385,8 @@ async function fetchChain(etag) {
|
|
|
181
385
|
continue;
|
|
182
386
|
}
|
|
183
387
|
try {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
if (!data || typeof data !== "object" || Array.isArray(data) || !Array.isArray(data.plugins)) {
|
|
187
|
-
throw new Error("响应缺少 plugins 数组");
|
|
188
|
-
}
|
|
189
|
-
return { ok: true, data, etag: response.headers.get("etag") || "", source: sourceUrl };
|
|
388
|
+
const data = parsePluginsPayload(await response.arrayBuffer());
|
|
389
|
+
return { ok: true, data, etag: response.headers.get('etag') || '', source: sourceUrl };
|
|
190
390
|
} catch (error) {
|
|
191
391
|
lastError = `${sourceUrl} → ${error.message}`;
|
|
192
392
|
continue;
|
|
@@ -195,17 +395,115 @@ async function fetchChain(etag) {
|
|
|
195
395
|
return { ok: false, error: `全部 awesome 源拉取失败:${lastError}` };
|
|
196
396
|
}
|
|
197
397
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
398
|
+
// ── 第二源拉取链(npm 版本轮,与主源 SHA 机制同构)─────────────────────────
|
|
399
|
+
|
|
400
|
+
let memSecondVersion = null; // { rootKey, version, at }
|
|
401
|
+
/** 版本锁定地址(双镜像);版本形态非法返回空数组,调用方跳过 pinned 链。 */
|
|
402
|
+
function secondUrlsOf(version) {
|
|
403
|
+
if (!SECOND_VERSION_PATTERN.test(String(version || ''))) return [];
|
|
404
|
+
return SECOND_MIRRORS.map((host) => `${host}/npm/${SECOND_CATALOG_PKG}@${version}/${SECOND_CATALOG_FILE}`);
|
|
405
|
+
}
|
|
406
|
+
/** 无版本回落地址(CDN 对 npm latest 的缓存最长 12h,仅解析失败时使用)。 */
|
|
407
|
+
const SECOND_FALLBACK_URLS = SECOND_MIRRORS.map((host) => `${host}/npm/${SECOND_CATALOG_PKG}/${SECOND_CATALOG_FILE}`);
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 解析第二源 npm 包 latest 版本号(registry 小 JSON)。失败(网络/限流/畸形)
|
|
411
|
+
* 返回 "",调用方回落无版本链——绝不让解析失败拖垮目录加载。备忘按缓存根分键,
|
|
412
|
+
* TTL 与 SHA 备忘一致。
|
|
413
|
+
*/
|
|
414
|
+
async function resolveSecondVersion(options = {}) {
|
|
415
|
+
const rootKey = memKeyOf(options);
|
|
416
|
+
if (memSecondVersion && memSecondVersion.rootKey === rootKey && Date.now() - memSecondVersion.at < SHA_MEMO_TTL_MS) {
|
|
417
|
+
return memSecondVersion.version;
|
|
418
|
+
}
|
|
419
|
+
let version = '';
|
|
420
|
+
try {
|
|
421
|
+
const response = await fetch(`https://registry.npmjs.org/${SECOND_CATALOG_PKG}/latest`, {
|
|
422
|
+
headers: { 'user-agent': USER_AGENT, accept: 'application/json' },
|
|
423
|
+
signal: AbortSignal.timeout(SECOND_VERSION_TIMEOUT_MS),
|
|
424
|
+
});
|
|
425
|
+
if (response.ok) {
|
|
426
|
+
const doc = await response.json();
|
|
427
|
+
if (doc && typeof doc.version === 'string' && SECOND_VERSION_PATTERN.test(doc.version)) version = doc.version;
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
/* registry 不可达:version 保持 "",走无版本回落链 */
|
|
431
|
+
}
|
|
432
|
+
memSecondVersion = { rootKey, version, at: Date.now() };
|
|
433
|
+
return version;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 第二源拉取链(两级):
|
|
438
|
+
* ① 首选版本口径:解析 npm latest 版本 → 与 meta.second.version 相同即内容
|
|
439
|
+
* 未变(不可变地址性质),直接 notModified;不同则按镜像序取 @<版本> 地址
|
|
440
|
+
* (CDN immutable,永不陈旧)。
|
|
441
|
+
* ② 回落无版本口径(registry 不可达 / pinned 链全挂时):条件 GET,304 且有
|
|
442
|
+
* etag → { ok, notModified }。
|
|
443
|
+
* 返回 { ok, notModified } | { ok, data, etag, source, version } | { ok: false, error }。
|
|
444
|
+
* 注意:② 成功时不含 version 字段(无版本地址不知道对应哪个构建,落盘 version
|
|
445
|
+
* 置空,下轮再验证重新解析)。
|
|
446
|
+
*/
|
|
447
|
+
async function fetchSecondChain(localSecond = {}, options = {}) {
|
|
448
|
+
const etag = localSecond && typeof localSecond.etag === 'string' ? localSecond.etag : '';
|
|
449
|
+
const localVersion = localSecond && typeof localSecond.version === 'string' ? localSecond.version : '';
|
|
450
|
+
let lastError = '';
|
|
451
|
+
|
|
452
|
+
// ① 版本口径:不可变地址
|
|
453
|
+
const version = await resolveSecondVersion(options);
|
|
454
|
+
if (version) {
|
|
455
|
+
if (version === localVersion) return { ok: true, notModified: true };
|
|
456
|
+
const pinnedErrors = [];
|
|
457
|
+
for (const sourceUrl of secondUrlsOf(version)) {
|
|
458
|
+
let response;
|
|
459
|
+
try {
|
|
460
|
+
response = await fetch(sourceUrl, {
|
|
461
|
+
headers: { 'user-agent': USER_AGENT },
|
|
462
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
463
|
+
});
|
|
464
|
+
} catch (error) {
|
|
465
|
+
pinnedErrors.push(`${sourceUrl} → ${error.message}`);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (!response.ok) {
|
|
469
|
+
pinnedErrors.push(`${sourceUrl} → HTTP ${response.status}`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
const data = parsePluginsPayload(await response.arrayBuffer());
|
|
474
|
+
return { ok: true, data, etag: response.headers.get('etag') || '', source: sourceUrl, version };
|
|
475
|
+
} catch (error) {
|
|
476
|
+
pinnedErrors.push(`${sourceUrl} → ${error.message}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
lastError = pinnedErrors.join('; ');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ② 无版本回落口径:条件 GET
|
|
483
|
+
const headers = { 'user-agent': USER_AGENT };
|
|
484
|
+
if (etag) headers['if-none-match'] = etag;
|
|
485
|
+
for (const sourceUrl of SECOND_FALLBACK_URLS) {
|
|
486
|
+
let response;
|
|
487
|
+
try {
|
|
488
|
+
response = await fetch(sourceUrl, { headers, signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
489
|
+
} catch (error) {
|
|
490
|
+
lastError = `${sourceUrl} → ${error.message}`;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (response.status === 304 && etag) return { ok: true, notModified: true };
|
|
494
|
+
if (!response.ok) {
|
|
495
|
+
lastError = `${sourceUrl} → HTTP ${response.status}`;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const data = parsePluginsPayload(await response.arrayBuffer());
|
|
500
|
+
return { ok: true, data, etag: response.headers.get('etag') || '', source: sourceUrl };
|
|
501
|
+
} catch (error) {
|
|
502
|
+
lastError = `${sourceUrl} → ${error.message}`;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { ok: false, error: `第二社区源拉取失败:${lastError}` };
|
|
209
507
|
}
|
|
210
508
|
|
|
211
509
|
// ── 进程内缓存 + 单航班后台再验证 ─────────────────────────────────────────
|
|
@@ -253,25 +551,121 @@ async function readSlimCache(options = {}, { useMem = true } = {}) {
|
|
|
253
551
|
return { entries, meta };
|
|
254
552
|
}
|
|
255
553
|
|
|
554
|
+
// ── 第二源缓存层(second.json = 适配后的第二源 npm 条目数组)────────────────
|
|
555
|
+
// 与 slim.json 分开落盘:合并去重在服务时做(主源补录条目会自然顶掉第二源
|
|
556
|
+
// 副本,无需重拉第二源);两源的元信息统一存 meta.json 的 second 键。
|
|
557
|
+
|
|
558
|
+
function secondPathOf(options = {}) {
|
|
559
|
+
return join(awesomeMarketDir(options), 'second.json');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
let memSecond = null; // { rootKey, entries }
|
|
563
|
+
|
|
564
|
+
function getMemSecond(options = {}) {
|
|
565
|
+
const key = memKeyOf(options);
|
|
566
|
+
return memSecond && memSecond.rootKey === key ? memSecond : null;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function setMemSecond(options = {}, entries) {
|
|
570
|
+
memSecond = { rootKey: memKeyOf(options), entries };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** 读第二源适配缓存;不存在/损坏返回 null,永不抛错。useMem 语义与 readSlimCache 同。 */
|
|
574
|
+
async function readSecondCache(options = {}, { useMem = true } = {}) {
|
|
575
|
+
if (useMem) {
|
|
576
|
+
const mem = getMemSecond(options);
|
|
577
|
+
if (mem) return mem.entries;
|
|
578
|
+
}
|
|
579
|
+
const entries = await readJsonAt(secondPathOf(options));
|
|
580
|
+
if (!Array.isArray(entries)) return null;
|
|
581
|
+
setMemSecond(options, entries);
|
|
582
|
+
return entries;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* 双源同步(refresh 同步路径与后台再验证共用):源1(fetchChain)与源2
|
|
587
|
+
* (fetchSecondChain)并行拉取、各自独立容错——任一源失败只保留自己的旧缓存,
|
|
588
|
+
* 绝不拖垮另一源。返回各源的新数据/缓存与判定标记。
|
|
589
|
+
*/
|
|
590
|
+
async function syncSources(slimCache, secondEntries, options = {}) {
|
|
591
|
+
const [chain1, chain2] = await Promise.all([
|
|
592
|
+
fetchChain(slimCache ? slimCache.meta : {}, options),
|
|
593
|
+
fetchSecondChain(slimCache && slimCache.meta && slimCache.meta.second ? slimCache.meta.second : {}, options),
|
|
594
|
+
]);
|
|
595
|
+
let entries1 = slimCache ? slimCache.entries : null;
|
|
596
|
+
if (chain1.ok && !chain1.notModified) entries1 = slimEntries(chain1.data.plugins);
|
|
597
|
+
let entries2 = Array.isArray(secondEntries) ? secondEntries : null;
|
|
598
|
+
if (chain2.ok && !chain2.notModified) entries2 = adaptSecondEntries(chain2.data.plugins);
|
|
599
|
+
const meta2Next =
|
|
600
|
+
chain2.ok && !chain2.notModified
|
|
601
|
+
? {
|
|
602
|
+
version: chain2.version || '',
|
|
603
|
+
etag: chain2.etag || '',
|
|
604
|
+
source: chain2.source || '',
|
|
605
|
+
fetchedAt: Date.now(),
|
|
606
|
+
updated: typeof chain2.data.updated === 'string' ? chain2.data.updated : '',
|
|
607
|
+
counts: countsOf(entries2),
|
|
608
|
+
}
|
|
609
|
+
: null;
|
|
610
|
+
return { chain1, chain2, entries1, entries2, meta2Next };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* 同步结果落盘:slim.json=源1瘦身条目(仅在源1有新数据时重写)、second.json=源2
|
|
615
|
+
* 适配条目(仅在源2有新数据时重写)、meta.json=源1元信息 + second 键(源2元信息;
|
|
616
|
+
* 以 base 展开重建,旧 meta 里的 second 在源2无新数据时原样保留)。
|
|
617
|
+
* fetchedAt 语义:源1「未变更」(SHA 相同 / 304)或「更新」都推到当前时刻以重置
|
|
618
|
+
* 陈旧计时;源1失败时保留原值(下轮尽快重试)。两源全部失败且无缓存时不动盘。
|
|
619
|
+
*/
|
|
620
|
+
async function persistSync({ chain1, chain2, slimCache, entries1, entries2, meta2Next }, options = {}) {
|
|
621
|
+
const meta1Changed = Boolean(chain1.ok && !chain1.notModified);
|
|
622
|
+
const meta2Changed = Boolean(chain2.ok && !chain2.notModified);
|
|
623
|
+
const base = slimCache && slimCache.meta ? slimCache.meta : {};
|
|
624
|
+
let finalMeta1;
|
|
625
|
+
if (meta1Changed) {
|
|
626
|
+
finalMeta1 = {
|
|
627
|
+
etag: chain1.etag || '',
|
|
628
|
+
fetchedAt: Date.now(),
|
|
629
|
+
updated: typeof chain1.data.updated === 'string' ? chain1.data.updated : '',
|
|
630
|
+
counts: countsOf(entries1),
|
|
631
|
+
source: chain1.source || '',
|
|
632
|
+
resolvedSha: typeof chain1.sha === 'string' ? chain1.sha : '',
|
|
633
|
+
};
|
|
634
|
+
} else {
|
|
635
|
+
finalMeta1 = { ...base, fetchedAt: chain1.ok ? Date.now() : Number(base.fetchedAt) || 0 };
|
|
636
|
+
}
|
|
637
|
+
finalMeta1.second = meta2Next || (base.second && typeof base.second === 'object' ? base.second : null);
|
|
638
|
+
if (meta1Changed) await writeAtomic(slimPathOf(options), JSON.stringify(entries1));
|
|
639
|
+
if (meta1Changed || chain1.ok || meta2Changed) {
|
|
640
|
+
await writeAtomic(metaPathOf(options), JSON.stringify(finalMeta1));
|
|
641
|
+
// 源1冷启动失败(entries1 为 null)时不污染内存层:slim 缓存仍视为不存在。
|
|
642
|
+
if (Array.isArray(entries1)) setMemSlim(options, entries1, finalMeta1);
|
|
643
|
+
}
|
|
644
|
+
if (meta2Next) {
|
|
645
|
+
await writeAtomic(secondPathOf(options), JSON.stringify(entries2));
|
|
646
|
+
setMemSecond(options, entries2);
|
|
647
|
+
}
|
|
648
|
+
return { finalMeta1, meta1Changed, meta2Changed };
|
|
649
|
+
}
|
|
650
|
+
|
|
256
651
|
/**
|
|
257
652
|
* 后台再验证:不阻塞调用方;单航班 + 失败退避。
|
|
258
|
-
*
|
|
259
|
-
*
|
|
653
|
+
* 双源同步(syncSources + persistSync):任一源成功即落盘相应部分,下次请求即拿
|
|
654
|
+
* 新结果;源1失败记退避(源2不受退避影响,但单航班内的本轮已并行试过);失败只
|
|
655
|
+
* 静默保留旧缓存(陈旧但可用,绝不把错误抛给 UI)。
|
|
260
656
|
*/
|
|
261
657
|
function revalidateInBackground(options = {}) {
|
|
262
658
|
if (revalidating) return revalidating;
|
|
263
659
|
if (revalidateFailedAt > 0 && Date.now() - revalidateFailedAt < REVALIDATE_BACKOFF_MS) return null;
|
|
264
660
|
revalidating = (async () => {
|
|
265
661
|
try {
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (!chain.notModified) await refreshFromNetwork(chain, options);
|
|
274
|
-
revalidateFailedAt = 0;
|
|
662
|
+
const slimCache = await readSlimCache(options);
|
|
663
|
+
const secondEntries = await readSecondCache(options);
|
|
664
|
+
const sync = await syncSources(slimCache, secondEntries, options);
|
|
665
|
+
// 源1失败也照常落盘:persistSync 会保留源1旧缓存、只写源2的新数据——
|
|
666
|
+
// 两源独立推进,任一源成功都不白跑。
|
|
667
|
+
await persistSync(sync, options);
|
|
668
|
+
revalidateFailedAt = sync.chain1.ok ? 0 : Date.now();
|
|
275
669
|
} catch {
|
|
276
670
|
revalidateFailedAt = Date.now();
|
|
277
671
|
} finally {
|
|
@@ -281,33 +675,15 @@ function revalidateInBackground(options = {}) {
|
|
|
281
675
|
return revalidating;
|
|
282
676
|
}
|
|
283
677
|
|
|
284
|
-
/**
|
|
285
|
-
*
|
|
286
|
-
* (否则缓存会永远被判为「陈旧」,每次进面板都白跑一次网络验证)。
|
|
287
|
-
* 写盘失败不影响返回值:内存层已更新。
|
|
288
|
-
*/
|
|
289
|
-
async function touchSlimMeta(options = {}, cache) {
|
|
290
|
-
const meta = { ...(cache.meta || {}), fetchedAt: Date.now() };
|
|
291
|
-
setMemSlim(options, cache.entries, meta);
|
|
292
|
-
try {
|
|
293
|
-
await writeAtomic(metaPathOf(options), JSON.stringify(meta));
|
|
294
|
-
} catch {
|
|
295
|
-
/* 元信息落盘失败可接受:下次仍会再验证一次 */
|
|
296
|
-
}
|
|
297
|
-
return meta;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** 统一组装 catalog 响应体(cache/disk 复用):counts 优先用 meta 里落盘的统计。 */
|
|
678
|
+
/** 统一组装 catalog 响应体(cache/disk 复用):entries 一律是合并后的面板条目,
|
|
679
|
+
* counts 服务时现算(第二源并入后 meta.counts 只记源1,作诊断不进响应)。 */
|
|
301
680
|
function catalogResult(entries, meta, loadedFrom, extra = {}) {
|
|
302
|
-
const counts =
|
|
303
|
-
meta && typeof meta.counts === "object" && meta.counts !== null && Number.isFinite(meta.counts.total)
|
|
304
|
-
? meta.counts
|
|
305
|
-
: countsOf(entries);
|
|
681
|
+
const counts = countsOf(entries);
|
|
306
682
|
const fetchedAt = Number(meta && meta.fetchedAt);
|
|
307
683
|
return {
|
|
308
684
|
ok: true,
|
|
309
685
|
loadedFrom,
|
|
310
|
-
updated: typeof meta.updated ===
|
|
686
|
+
updated: typeof meta.updated === 'string' ? meta.updated : '',
|
|
311
687
|
fetchedAt: Number.isFinite(fetchedAt) && fetchedAt > 0 ? fetchedAt : 0,
|
|
312
688
|
counts,
|
|
313
689
|
plugins: entries,
|
|
@@ -316,33 +692,21 @@ function catalogResult(entries, meta, loadedFrom, extra = {}) {
|
|
|
316
692
|
};
|
|
317
693
|
}
|
|
318
694
|
|
|
319
|
-
/** 拉取链成功后的统一走线:瘦身 → 原子落盘 → 组装 network 响应。 */
|
|
320
|
-
async function refreshFromNetwork(chain, options = {}) {
|
|
321
|
-
const entries = slimEntries(chain.data.plugins);
|
|
322
|
-
const counts = await persistSlim(entries, chain.etag, typeof chain.data.updated === "string" ? chain.data.updated : "", options, typeof chain.source === "string" ? chain.source : "");
|
|
323
|
-
return {
|
|
324
|
-
ok: true,
|
|
325
|
-
loadedFrom: "network",
|
|
326
|
-
updated: typeof chain.data.updated === "string" ? chain.data.updated : "",
|
|
327
|
-
fetchedAt: Date.now(),
|
|
328
|
-
counts,
|
|
329
|
-
plugins: entries,
|
|
330
|
-
marketplace: AWESOME_MARKETPLACE_ID,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
695
|
// ── 四个对外 API ───────────────────────────────────────────────────────────
|
|
335
696
|
|
|
336
697
|
/**
|
|
337
|
-
* 读 awesome 目录(缓存优先 + 后台再验证 →
|
|
338
|
-
* 1. refresh: true(UI「重新加载」按钮)→
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
698
|
+
* 读 awesome 目录(缓存优先 + 后台再验证 → 首屏秒出;双源合并):
|
|
699
|
+
* 1. refresh: true(UI「重新加载」按钮)→ 同步强刷:两源并行(syncSources),
|
|
700
|
+
* 各源未变更复用缓存 / 有新数据重新落盘(任一源有更新 → loadedFrom
|
|
701
|
+
* "network");源1全源失败且有缓存回落 "disk" + stale: true;无缓存且源1
|
|
702
|
+
* 失败 { ok: false }(源2失败只静默少并一块,不拖垮面板)。
|
|
703
|
+
* 2. 缓存新鲜(龄期 < REVALIDATE_AFTER_MS)→ 直接返合并缓存("cache"),完全不
|
|
704
|
+
* 碰网络。
|
|
705
|
+
* 3. 缓存陈旧 → 仍立刻返回旧合并缓存("cache" + refreshing: true),网络验证
|
|
706
|
+
* 丢给后台单航班;下次进面板即拿到新数据,失败静默保留旧缓存。
|
|
707
|
+
* 4. 无缓存(真正的首次)→ 双源并行全量拉取 → 瘦身/适配落盘("network")。
|
|
708
|
+
* 面板条目 = 源1瘦身条目 + 第二源 npm 条目中主源没有的(mergeSecondEntries,
|
|
709
|
+
* 服务时现算);counts 按合并结果统计。
|
|
346
710
|
* 响应统一带 fetchedAt(缓存抓取时刻)与(陈旧分支的)refreshing 标记,
|
|
347
711
|
* 供 UI 直接显示「后台更新中」而无需二次请求。
|
|
348
712
|
* 永不抛错(拉取/解析/写盘异常都折进返回体)。
|
|
@@ -350,35 +714,43 @@ async function refreshFromNetwork(chain, options = {}) {
|
|
|
350
714
|
export async function wbAwesomeCatalog(options = {}) {
|
|
351
715
|
const wantRefresh = Boolean(options) && options.refresh === true;
|
|
352
716
|
try {
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
717
|
+
const slimCache = await readSlimCache(options, { useMem: !wantRefresh });
|
|
718
|
+
const secondEntries = await readSecondCache(options, { useMem: !wantRefresh });
|
|
719
|
+
if (slimCache && !wantRefresh && ageOfMeta(slimCache.meta) < REVALIDATE_AFTER_MS) {
|
|
720
|
+
return catalogResult(mergeSecondEntries(slimCache.entries, secondEntries), slimCache.meta, 'cache');
|
|
721
|
+
}
|
|
722
|
+
if (slimCache && !wantRefresh) {
|
|
723
|
+
// 陈旧缓存:先秒回旧数据,网络验证交给后台(单航班 + 失败退避)。
|
|
724
|
+
revalidateInBackground(options);
|
|
725
|
+
return catalogResult(mergeSecondEntries(slimCache.entries, secondEntries), slimCache.meta, 'cache', {
|
|
726
|
+
refreshing: true,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
// UI 强刷或冷启动:两源并行同步,各自独立容错。
|
|
730
|
+
const { chain1, chain2, entries1, entries2, meta2Next } = await syncSources(slimCache, secondEntries, options);
|
|
731
|
+
// persistSync 先于成败判定:它只在 meta2Next 存在时写 second.json,源1失败不动 slim 层——
|
|
732
|
+
// 「两源独立推进,任一源成功都不白跑」与后台单航班同规。放在 !entries1 早退之后的话,
|
|
733
|
+
// 冷启动遇源1全链失败时,源2刚拉到的整包会被原样丢掉,下次进面板还得重拉一遍。
|
|
734
|
+
const { finalMeta1, meta1Changed, meta2Changed } = await persistSync(
|
|
735
|
+
{ chain1, chain2, slimCache, entries1, entries2, meta2Next },
|
|
736
|
+
options,
|
|
737
|
+
);
|
|
738
|
+
if (!entries1) {
|
|
739
|
+
// 源1无货且无缓存:面板主体缺失,维持 ok:false 语义(第二源单飞不成板)。
|
|
740
|
+
return { ok: false, error: chain1.ok ? 'awesome 目录加载异常' : chain1.error };
|
|
369
741
|
}
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
return
|
|
742
|
+
const merged = mergeSecondEntries(entries1, entries2);
|
|
743
|
+
const loadedFrom = chain1.ok ? (meta1Changed || meta2Changed ? 'network' : 'cache') : 'disk';
|
|
744
|
+
return catalogResult(merged, finalMeta1, loadedFrom, chain1.ok ? {} : { stale: true });
|
|
373
745
|
} catch (error) {
|
|
374
746
|
return { ok: false, error: `awesome 目录加载异常:${error.message}` };
|
|
375
747
|
}
|
|
376
748
|
}
|
|
377
749
|
|
|
378
|
-
/**
|
|
750
|
+
/** 按名字查合并后的面板条目;无缓存先走 wbAwesomeCatalog 全量链;
|
|
379
751
|
* 名字不存在 / 加载失败返回 null。 */
|
|
380
752
|
export async function wbAwesomeDetail(name, options = {}) {
|
|
381
|
-
const wanted = typeof name ===
|
|
753
|
+
const wanted = typeof name === 'string' ? name.trim() : '';
|
|
382
754
|
if (!wanted) return null;
|
|
383
755
|
try {
|
|
384
756
|
let cache = await readSlimCache(options);
|
|
@@ -387,7 +759,9 @@ export async function wbAwesomeDetail(name, options = {}) {
|
|
|
387
759
|
if (!fresh.ok) return null;
|
|
388
760
|
return fresh.plugins.find((entry) => entry.name === wanted) ?? null;
|
|
389
761
|
}
|
|
390
|
-
|
|
762
|
+
const secondEntries = await readSecondCache(options);
|
|
763
|
+
const merged = mergeSecondEntries(cache.entries, secondEntries);
|
|
764
|
+
return merged.find((entry) => entry.name === wanted) ?? null;
|
|
391
765
|
} catch {
|
|
392
766
|
return null;
|
|
393
767
|
}
|
|
@@ -398,14 +772,14 @@ export async function wbAwesomeDetail(name, options = {}) {
|
|
|
398
772
|
* 绝不落盘,只取 headers。返回 { ok: true, remoteEtag } 或 { ok: false, error }。
|
|
399
773
|
*/
|
|
400
774
|
async function probeEtagAt(sourceUrl, localEtag) {
|
|
401
|
-
const headers = {
|
|
775
|
+
const headers = { 'user-agent': USER_AGENT, 'if-none-match': localEtag };
|
|
402
776
|
let response;
|
|
403
777
|
try {
|
|
404
|
-
response = await fetch(sourceUrl, { method:
|
|
778
|
+
response = await fetch(sourceUrl, { method: 'HEAD', headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
405
779
|
if (response.status === 405 || response.status === 501) {
|
|
406
780
|
// HEAD 不被支持 → 降级 GET,立刻 cancel() 丢弃响应体,不读进内存。
|
|
407
781
|
response.body?.cancel?.();
|
|
408
|
-
response = await fetch(sourceUrl, { method:
|
|
782
|
+
response = await fetch(sourceUrl, { method: 'GET', headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
409
783
|
response.body?.cancel?.();
|
|
410
784
|
}
|
|
411
785
|
} catch (error) {
|
|
@@ -413,7 +787,7 @@ async function probeEtagAt(sourceUrl, localEtag) {
|
|
|
413
787
|
}
|
|
414
788
|
if (response.status === 304) return { ok: true, remoteEtag: localEtag };
|
|
415
789
|
if (response.ok) {
|
|
416
|
-
const remoteEtag = response.headers.get(
|
|
790
|
+
const remoteEtag = response.headers.get('etag') || '';
|
|
417
791
|
// 探测只信 ETag:响应没有 ETag 就无法判定,宁可报探测失败也不误报「有更新」。
|
|
418
792
|
if (!remoteEtag) return { ok: false, error: `${sourceUrl} → 响应缺少 ETag` };
|
|
419
793
|
return { ok: true, remoteEtag };
|
|
@@ -422,27 +796,49 @@ async function probeEtagAt(sourceUrl, localEtag) {
|
|
|
422
796
|
}
|
|
423
797
|
|
|
424
798
|
/**
|
|
425
|
-
*
|
|
799
|
+
* 更新探测:**绝不落盘**,只做判定。
|
|
426
800
|
* reason 五枚举:no-source / probe-failed / no-cache / up-to-date / update-available。
|
|
427
|
-
* 无缓存(meta 无 etag)→ hasUpdate: false +
|
|
428
|
-
*
|
|
801
|
+
* 无缓存(meta 无 etag、无 resolvedSha、second 无 version)→ hasUpdate: false +
|
|
802
|
+
* no-cache(awesome 首次由 catalog 链兜底加载,探测层不引导更新)。
|
|
429
803
|
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
804
|
+
* 双源两级口径:
|
|
805
|
+
* ① 首选不可变口径:并行解析源1提交 SHA 与源2 npm 版本号,与缓存记录直接比对
|
|
806
|
+
* ——不可变地址的性质保证「同 SHA/同版本即同内容」,判定不受 CDN 陈旧窗口
|
|
807
|
+
* 影响(这正是旧口径「@main + ETag」被陈旧边缘 304 骗过、报出虚假
|
|
808
|
+
* up-to-date 的根因)。任一源有更新即报 update-available;旧缓存缺记录而
|
|
809
|
+
* 解析成功时按 update-available 报(大概率确实落后,且引导一次刷新后自愈:
|
|
810
|
+
* 落盘记录后判定恢复精确)。
|
|
811
|
+
* ② 回落 ETag 口径(两级解析都失败时):按「缓存实际来源 → 全部源」顺序 HEAD
|
|
812
|
+
* 条件探测(HEAD 被拒降级 GET 并 cancel 响应体),首个拿到 ETag 的源胜出;
|
|
813
|
+
* 全部失败才报 probe-failed。
|
|
434
814
|
*/
|
|
435
815
|
export async function wbAwesomeUpdateCheck(options = {}) {
|
|
436
816
|
try {
|
|
437
817
|
const meta = (await readJsonAt(metaPathOf(options))) || {};
|
|
438
|
-
const localEtag = typeof meta.etag ===
|
|
439
|
-
|
|
440
|
-
|
|
818
|
+
const localEtag = typeof meta.etag === 'string' ? meta.etag : '';
|
|
819
|
+
const localSha = typeof meta.resolvedSha === 'string' ? meta.resolvedSha : '';
|
|
820
|
+
const second = meta.second && typeof meta.second === 'object' ? meta.second : {};
|
|
821
|
+
const localVersion2 = typeof second.version === 'string' ? second.version : '';
|
|
822
|
+
if (!localEtag && !localSha && !localVersion2) {
|
|
823
|
+
return { ok: true, hasUpdate: false, reason: 'no-cache', localEtag: '', remoteEtag: '', checkedAt: Date.now() };
|
|
441
824
|
}
|
|
442
|
-
//
|
|
443
|
-
const
|
|
825
|
+
// ① 不可变口径:源1比对提交 SHA,源2比对 npm 版本号,任一前进即有更新
|
|
826
|
+
const [sha, version2] = await Promise.all([resolveBranchSha(options), resolveSecondVersion(options)]);
|
|
827
|
+
if (sha || version2) {
|
|
828
|
+
const hasUpdate = (Boolean(sha) && sha !== localSha) || (Boolean(version2) && version2 !== localVersion2);
|
|
829
|
+
return {
|
|
830
|
+
ok: true,
|
|
831
|
+
hasUpdate,
|
|
832
|
+
reason: hasUpdate ? 'update-available' : 'up-to-date',
|
|
833
|
+
localEtag,
|
|
834
|
+
remoteEtag: hasUpdate ? '' : localEtag,
|
|
835
|
+
checkedAt: Date.now(),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
// ② ETag 回落口径:先问拿到这份缓存的源(ETag 同源可比),再兜底其余源。
|
|
839
|
+
const cachedSource = typeof meta.source === 'string' ? meta.source : '';
|
|
444
840
|
const candidates = [...new Set([cachedSource, ...AWESOME_SOURCES].filter(Boolean))];
|
|
445
|
-
let lastError =
|
|
841
|
+
let lastError = '';
|
|
446
842
|
for (const sourceUrl of candidates) {
|
|
447
843
|
const probe = await probeEtagAt(sourceUrl, localEtag);
|
|
448
844
|
if (!probe.ok) {
|
|
@@ -450,24 +846,47 @@ export async function wbAwesomeUpdateCheck(options = {}) {
|
|
|
450
846
|
continue;
|
|
451
847
|
}
|
|
452
848
|
const hasUpdate = probe.remoteEtag !== localEtag;
|
|
453
|
-
return {
|
|
849
|
+
return {
|
|
850
|
+
ok: true,
|
|
851
|
+
hasUpdate,
|
|
852
|
+
reason: hasUpdate ? 'update-available' : 'up-to-date',
|
|
853
|
+
localEtag,
|
|
854
|
+
remoteEtag: probe.remoteEtag,
|
|
855
|
+
checkedAt: Date.now(),
|
|
856
|
+
};
|
|
454
857
|
}
|
|
455
|
-
return {
|
|
858
|
+
return {
|
|
859
|
+
ok: false,
|
|
860
|
+
hasUpdate: false,
|
|
861
|
+
reason: 'probe-failed',
|
|
862
|
+
localEtag,
|
|
863
|
+
remoteEtag: '',
|
|
864
|
+
checkedAt: Date.now(),
|
|
865
|
+
error: `awesome 市场更新探测失败:${lastError}`,
|
|
866
|
+
};
|
|
456
867
|
} catch (error) {
|
|
457
|
-
return {
|
|
868
|
+
return {
|
|
869
|
+
ok: false,
|
|
870
|
+
hasUpdate: false,
|
|
871
|
+
reason: 'probe-failed',
|
|
872
|
+
localEtag: '',
|
|
873
|
+
remoteEtag: '',
|
|
874
|
+
checkedAt: Date.now(),
|
|
875
|
+
error: `更新探测异常:${error.message}`,
|
|
876
|
+
};
|
|
458
877
|
}
|
|
459
878
|
}
|
|
460
879
|
|
|
461
880
|
/** 缓存元信息(只读,不联网):{ hasCache, etag, fetchedAt, updated }。 */
|
|
462
881
|
export async function wbAwesomeCachedInfo(options = {}) {
|
|
463
882
|
const cache = await readSlimCache(options);
|
|
464
|
-
if (!cache) return { hasCache: false, etag:
|
|
883
|
+
if (!cache) return { hasCache: false, etag: '', fetchedAt: 0, updated: '' };
|
|
465
884
|
const meta = cache.meta;
|
|
466
885
|
return {
|
|
467
886
|
hasCache: true,
|
|
468
|
-
etag: typeof meta.etag ===
|
|
469
|
-
fetchedAt: typeof meta.fetchedAt ===
|
|
470
|
-
updated: typeof meta.updated ===
|
|
887
|
+
etag: typeof meta.etag === 'string' ? meta.etag : '',
|
|
888
|
+
fetchedAt: typeof meta.fetchedAt === 'number' ? meta.fetchedAt : 0,
|
|
889
|
+
updated: typeof meta.updated === 'string' ? meta.updated : '',
|
|
471
890
|
};
|
|
472
891
|
}
|
|
473
892
|
|
|
@@ -484,16 +903,20 @@ const README_MAX_CHARS = 160_000;
|
|
|
484
903
|
/** 磁盘缓存 TTL:README 变动不频繁,7 天足够,过期再取一次。 */
|
|
485
904
|
const README_CACHE_TTL_MS = 7 * 24 * 3600_000;
|
|
486
905
|
/** GitHub raw 里按顺序尝试的文件名(大小写在不同仓库里真的不一样)。 */
|
|
487
|
-
const README_FILES = [
|
|
488
|
-
const NPM_REGISTRY =
|
|
906
|
+
const README_FILES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README.markdown', 'README_EN.md'];
|
|
907
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
489
908
|
|
|
490
909
|
function readmeCacheDir(options = {}) {
|
|
491
|
-
return join(awesomeMarketDir(options),
|
|
910
|
+
return join(awesomeMarketDir(options), 'readme');
|
|
492
911
|
}
|
|
493
912
|
|
|
494
913
|
/** 缓存文件名:来源标识压成安全短名(不用 crypto,避免多一个依赖)。 */
|
|
495
914
|
function readmeCachePath(key, options = {}) {
|
|
496
|
-
const safe =
|
|
915
|
+
const safe =
|
|
916
|
+
String(key || '')
|
|
917
|
+
.toLowerCase()
|
|
918
|
+
.replace(/[^a-z0-9._-]+/g, '_')
|
|
919
|
+
.slice(0, 96) || 'item';
|
|
497
920
|
return join(readmeCacheDir(options), `${safe}.json`);
|
|
498
921
|
}
|
|
499
922
|
|
|
@@ -503,22 +926,24 @@ function readmeCachePath(key, options = {}) {
|
|
|
503
926
|
* `git@github.com:o/r.git`、带 `#readme` / `/tree/main` 尾巴的。
|
|
504
927
|
*/
|
|
505
928
|
export function parseGithubRepo(value) {
|
|
506
|
-
if (typeof value !==
|
|
929
|
+
if (typeof value !== 'string') return '';
|
|
507
930
|
let text = value.trim();
|
|
508
|
-
if (!text) return
|
|
931
|
+
if (!text) return '';
|
|
509
932
|
const ssh = /^git@github\.com:([^/\s]+)\/([^/\s#]+?)(?:\.git)?$/i.exec(text);
|
|
510
|
-
if (ssh) return `${ssh[1]}/${ssh[2].replace(/\.git$/i,
|
|
511
|
-
text = text.replace(/^git\+/i,
|
|
933
|
+
if (ssh) return `${ssh[1]}/${ssh[2].replace(/\.git$/i, '')}`;
|
|
934
|
+
text = text.replace(/^git\+/i, '');
|
|
512
935
|
const http = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#?]+)/i.exec(text);
|
|
513
|
-
if (http) return `${http[1]}/${http[2].replace(/\.git$/i,
|
|
936
|
+
if (http) return `${http[1]}/${http[2].replace(/\.git$/i, '')}`;
|
|
514
937
|
const bare = /^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9._-]+)$/.exec(text);
|
|
515
|
-
if (bare) return `${bare[1]}/${bare[2].replace(/\.git$/i,
|
|
516
|
-
return
|
|
938
|
+
if (bare) return `${bare[1]}/${bare[2].replace(/\.git$/i, '')}`;
|
|
939
|
+
return '';
|
|
517
940
|
}
|
|
518
941
|
|
|
519
942
|
/** 正文规整:去 BOM、统一换行、按字符上限截断。 */
|
|
520
943
|
function normalizeReadme(text) {
|
|
521
|
-
let out = String(text ||
|
|
944
|
+
let out = String(text || '')
|
|
945
|
+
.replace(/^\uFEFF/, '')
|
|
946
|
+
.replace(/\r\n?/g, '\n');
|
|
522
947
|
if (out.length > README_MAX_CHARS) {
|
|
523
948
|
return { markdown: out.slice(0, README_MAX_CHARS), truncated: true };
|
|
524
949
|
}
|
|
@@ -531,9 +956,9 @@ async function fetchReadmeText(url, accept) {
|
|
|
531
956
|
const timer = setTimeout(() => controller.abort(), README_TIMEOUT_MS);
|
|
532
957
|
try {
|
|
533
958
|
const response = await fetch(url, {
|
|
534
|
-
headers: {
|
|
959
|
+
headers: { 'user-agent': USER_AGENT, accept },
|
|
535
960
|
signal: controller.signal,
|
|
536
|
-
redirect:
|
|
961
|
+
redirect: 'follow',
|
|
537
962
|
});
|
|
538
963
|
if (!response.ok) {
|
|
539
964
|
try {
|
|
@@ -543,9 +968,18 @@ async function fetchReadmeText(url, accept) {
|
|
|
543
968
|
}
|
|
544
969
|
return { ok: false, reachable: true, status: response.status, error: `HTTP ${response.status}` };
|
|
545
970
|
}
|
|
546
|
-
return {
|
|
971
|
+
return {
|
|
972
|
+
ok: true,
|
|
973
|
+
reachable: true,
|
|
974
|
+
status: response.status,
|
|
975
|
+
text: await response.text(),
|
|
976
|
+
url: response.url || url,
|
|
977
|
+
};
|
|
547
978
|
} catch (error) {
|
|
548
|
-
const reason =
|
|
979
|
+
const reason =
|
|
980
|
+
error && error.name === 'AbortError'
|
|
981
|
+
? `超时(${README_TIMEOUT_MS}ms)`
|
|
982
|
+
: String((error && error.message) || error);
|
|
549
983
|
return { ok: false, reachable: false, status: 0, error: reason };
|
|
550
984
|
} finally {
|
|
551
985
|
clearTimeout(timer);
|
|
@@ -554,8 +988,8 @@ async function fetchReadmeText(url, accept) {
|
|
|
554
988
|
|
|
555
989
|
/** npm 侧取 README:先打 `/<pkg>/latest`(小),没有再退全量 packument。 */
|
|
556
990
|
async function readmeFromNpm(npmName) {
|
|
557
|
-
const encoded = String(npmName).replace(/\//g,
|
|
558
|
-
const latest = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}/latest`,
|
|
991
|
+
const encoded = String(npmName).replace(/\//g, '%2F');
|
|
992
|
+
const latest = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}/latest`, 'application/json');
|
|
559
993
|
if (latest.ok) {
|
|
560
994
|
let doc = null;
|
|
561
995
|
try {
|
|
@@ -563,11 +997,16 @@ async function readmeFromNpm(npmName) {
|
|
|
563
997
|
} catch {
|
|
564
998
|
doc = null;
|
|
565
999
|
}
|
|
566
|
-
if (doc && typeof doc.readme ===
|
|
567
|
-
return {
|
|
1000
|
+
if (doc && typeof doc.readme === 'string' && doc.readme.trim()) {
|
|
1001
|
+
return {
|
|
1002
|
+
ok: true,
|
|
1003
|
+
markdown: doc.readme,
|
|
1004
|
+
repo: parseGithubRepo(doc.repository),
|
|
1005
|
+
file: doc.readmeFilename || 'README.md',
|
|
1006
|
+
};
|
|
568
1007
|
}
|
|
569
1008
|
}
|
|
570
|
-
const full = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}`,
|
|
1009
|
+
const full = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}`, 'application/json');
|
|
571
1010
|
if (!full.ok) {
|
|
572
1011
|
return { ok: false, error: full.error };
|
|
573
1012
|
}
|
|
@@ -575,13 +1014,18 @@ async function readmeFromNpm(npmName) {
|
|
|
575
1014
|
try {
|
|
576
1015
|
packument = JSON.parse(full.text);
|
|
577
1016
|
} catch {
|
|
578
|
-
return { ok: false, error:
|
|
1017
|
+
return { ok: false, error: 'npm 返回的不是合法 JSON' };
|
|
579
1018
|
}
|
|
580
|
-
const markdown = packument && typeof packument.readme ===
|
|
1019
|
+
const markdown = packument && typeof packument.readme === 'string' ? packument.readme : '';
|
|
581
1020
|
if (!markdown.trim()) {
|
|
582
|
-
return { ok: false, error: latest.ok ?
|
|
1021
|
+
return { ok: false, error: latest.ok ? 'npm 包没有 README' : `${latest.error};npm 包没有 README` };
|
|
583
1022
|
}
|
|
584
|
-
return {
|
|
1023
|
+
return {
|
|
1024
|
+
ok: true,
|
|
1025
|
+
markdown,
|
|
1026
|
+
repo: parseGithubRepo(packument.repository),
|
|
1027
|
+
file: packument.readmeFilename || 'README.md',
|
|
1028
|
+
};
|
|
585
1029
|
}
|
|
586
1030
|
|
|
587
1031
|
/**
|
|
@@ -606,16 +1050,16 @@ function readmeGithubBases(repo) {
|
|
|
606
1050
|
* 文件名全试完即可收工(不再换基底,避免对一个没 README 的仓库打 30 次请求)。
|
|
607
1051
|
*/
|
|
608
1052
|
async function readmeFromGithub(repo) {
|
|
609
|
-
let lastError =
|
|
1053
|
+
let lastError = '';
|
|
610
1054
|
let sawReachable = false;
|
|
611
1055
|
for (const base of readmeGithubBases(repo)) {
|
|
612
1056
|
for (const file of README_FILES) {
|
|
613
|
-
const attempt = await fetchReadmeText(`${base}${file}`,
|
|
1057
|
+
const attempt = await fetchReadmeText(`${base}${file}`, 'text/plain');
|
|
614
1058
|
if (attempt.ok && attempt.text.trim()) {
|
|
615
1059
|
return { ok: true, markdown: attempt.text, base, file };
|
|
616
1060
|
}
|
|
617
1061
|
if (attempt.ok) {
|
|
618
|
-
lastError =
|
|
1062
|
+
lastError = '文件为空';
|
|
619
1063
|
sawReachable = true;
|
|
620
1064
|
continue;
|
|
621
1065
|
}
|
|
@@ -629,7 +1073,7 @@ async function readmeFromGithub(repo) {
|
|
|
629
1073
|
}
|
|
630
1074
|
if (sawReachable) break;
|
|
631
1075
|
}
|
|
632
|
-
return { ok: false, error: lastError ||
|
|
1076
|
+
return { ok: false, error: lastError || '未找到 README' };
|
|
633
1077
|
}
|
|
634
1078
|
|
|
635
1079
|
/**
|
|
@@ -641,55 +1085,71 @@ async function readmeFromGithub(repo) {
|
|
|
641
1085
|
*
|
|
642
1086
|
* `base` 是相对图片/链接的解析基准(GitHub raw 目录),客户端据此把 README 里的
|
|
643
1087
|
* 相对路径 `assets/a.png` 还原成可加载的绝对 URL —— 不还原的话插图全是坏图。
|
|
1088
|
+
*
|
|
1089
|
+
* 第二参 `options` 只承载存储位置(`rootDir`,测试隔离用);RPC 层不传、走真实 `~/.dsh`。
|
|
644
1090
|
*/
|
|
645
|
-
export async function wbAwesomeReadme(payload = {}) {
|
|
646
|
-
const name = typeof payload.name ===
|
|
1091
|
+
export async function wbAwesomeReadme(payload = {}, options = {}) {
|
|
1092
|
+
const name = typeof payload.name === 'string' ? payload.name.trim() : '';
|
|
647
1093
|
const npmName =
|
|
648
|
-
typeof payload.npm ===
|
|
1094
|
+
typeof payload.npm === 'string'
|
|
649
1095
|
? payload.npm.trim()
|
|
650
|
-
: payload.npm && typeof payload.npm ===
|
|
1096
|
+
: payload.npm && typeof payload.npm === 'object' && typeof payload.npm.name === 'string'
|
|
651
1097
|
? payload.npm.name.trim()
|
|
652
|
-
:
|
|
1098
|
+
: '';
|
|
653
1099
|
let repo = parseGithubRepo(payload.repo);
|
|
654
1100
|
const wantRefresh = payload.refresh === true;
|
|
655
1101
|
if (!npmName && !repo) {
|
|
656
|
-
return {
|
|
1102
|
+
return {
|
|
1103
|
+
ok: false,
|
|
1104
|
+
reason: 'no-source',
|
|
1105
|
+
name,
|
|
1106
|
+
npm: '',
|
|
1107
|
+
repo: '',
|
|
1108
|
+
markdown: '',
|
|
1109
|
+
truncated: false,
|
|
1110
|
+
source: '',
|
|
1111
|
+
base: '',
|
|
1112
|
+
fetchedAt: 0,
|
|
1113
|
+
error: '该条目没有 npm 包名或 GitHub 仓库地址,无法读取 README',
|
|
1114
|
+
};
|
|
657
1115
|
}
|
|
658
1116
|
const cacheKey = npmName || repo;
|
|
659
|
-
|
|
1117
|
+
// 缓存路径只认 options(rootDir 测试隔离契约,与 wbAwesomeCatalog 同规);
|
|
1118
|
+
// payload 是 RPC 入参,把它当 options 传会让显式 rootDir 失效、测试打到真实 ~/.dsh。
|
|
1119
|
+
const cachePath = readmeCachePath(cacheKey, options);
|
|
660
1120
|
if (!wantRefresh) {
|
|
661
1121
|
const cached = await readJsonAt(cachePath);
|
|
662
1122
|
if (
|
|
663
1123
|
cached &&
|
|
664
|
-
typeof cached.markdown ===
|
|
1124
|
+
typeof cached.markdown === 'string' &&
|
|
665
1125
|
cached.markdown &&
|
|
666
1126
|
Date.now() - Number(cached.fetchedAt || 0) < README_CACHE_TTL_MS
|
|
667
1127
|
) {
|
|
668
1128
|
return {
|
|
669
1129
|
ok: true,
|
|
670
|
-
reason:
|
|
1130
|
+
reason: 'cache',
|
|
671
1131
|
name,
|
|
672
1132
|
npm: npmName,
|
|
673
|
-
repo: typeof cached.repo ===
|
|
1133
|
+
repo: typeof cached.repo === 'string' ? cached.repo : repo,
|
|
674
1134
|
markdown: cached.markdown,
|
|
675
1135
|
truncated: Boolean(cached.truncated),
|
|
676
|
-
source: typeof cached.source ===
|
|
677
|
-
base: typeof cached.base ===
|
|
1136
|
+
source: typeof cached.source === 'string' ? cached.source : '',
|
|
1137
|
+
base: typeof cached.base === 'string' ? cached.base : '',
|
|
678
1138
|
fetchedAt: Number(cached.fetchedAt || 0),
|
|
679
1139
|
};
|
|
680
1140
|
}
|
|
681
1141
|
}
|
|
682
|
-
let markdown =
|
|
683
|
-
let source =
|
|
684
|
-
let base =
|
|
1142
|
+
let markdown = '';
|
|
1143
|
+
let source = '';
|
|
1144
|
+
let base = '';
|
|
685
1145
|
const errors = [];
|
|
686
1146
|
if (npmName) {
|
|
687
1147
|
const fromNpm = await readmeFromNpm(npmName);
|
|
688
1148
|
if (fromNpm.ok) {
|
|
689
1149
|
markdown = fromNpm.markdown;
|
|
690
|
-
source =
|
|
1150
|
+
source = 'npm';
|
|
691
1151
|
repo = repo || parseGithubRepo(fromNpm.repo);
|
|
692
|
-
base = repo ? `https://raw.githubusercontent.com/${repo}/HEAD/` :
|
|
1152
|
+
base = repo ? `https://raw.githubusercontent.com/${repo}/HEAD/` : '';
|
|
693
1153
|
} else {
|
|
694
1154
|
errors.push(`npm:${fromNpm.error}`);
|
|
695
1155
|
}
|
|
@@ -698,24 +1158,56 @@ export async function wbAwesomeReadme(payload = {}) {
|
|
|
698
1158
|
const fromGithub = await readmeFromGithub(repo);
|
|
699
1159
|
if (fromGithub.ok) {
|
|
700
1160
|
markdown = fromGithub.markdown;
|
|
701
|
-
source =
|
|
1161
|
+
source = 'github';
|
|
702
1162
|
base = fromGithub.base;
|
|
703
1163
|
} else {
|
|
704
1164
|
errors.push(`github:${fromGithub.error}`);
|
|
705
1165
|
}
|
|
706
1166
|
}
|
|
707
1167
|
if (!markdown.trim()) {
|
|
708
|
-
return {
|
|
1168
|
+
return {
|
|
1169
|
+
ok: false,
|
|
1170
|
+
reason: 'readme-unavailable',
|
|
1171
|
+
name,
|
|
1172
|
+
npm: npmName,
|
|
1173
|
+
repo,
|
|
1174
|
+
markdown: '',
|
|
1175
|
+
truncated: false,
|
|
1176
|
+
source: '',
|
|
1177
|
+
base: '',
|
|
1178
|
+
fetchedAt: 0,
|
|
1179
|
+
error: `读取 README 失败:${errors.join(';') || '没有可用来源'}`,
|
|
1180
|
+
};
|
|
709
1181
|
}
|
|
710
1182
|
const normalized = normalizeReadme(markdown);
|
|
711
1183
|
const fetchedAt = Date.now();
|
|
712
1184
|
try {
|
|
713
1185
|
await writeAtomic(
|
|
714
1186
|
cachePath,
|
|
715
|
-
JSON.stringify({
|
|
1187
|
+
JSON.stringify({
|
|
1188
|
+
name,
|
|
1189
|
+
npm: npmName,
|
|
1190
|
+
repo,
|
|
1191
|
+
markdown: normalized.markdown,
|
|
1192
|
+
truncated: normalized.truncated,
|
|
1193
|
+
source,
|
|
1194
|
+
base,
|
|
1195
|
+
fetchedAt,
|
|
1196
|
+
}),
|
|
716
1197
|
);
|
|
717
1198
|
} catch {
|
|
718
1199
|
// 缓存写不进去不影响这次预览
|
|
719
1200
|
}
|
|
720
|
-
return {
|
|
1201
|
+
return {
|
|
1202
|
+
ok: true,
|
|
1203
|
+
reason: 'network',
|
|
1204
|
+
name,
|
|
1205
|
+
npm: npmName,
|
|
1206
|
+
repo,
|
|
1207
|
+
markdown: normalized.markdown,
|
|
1208
|
+
truncated: normalized.truncated,
|
|
1209
|
+
source,
|
|
1210
|
+
base,
|
|
1211
|
+
fetchedAt,
|
|
1212
|
+
};
|
|
721
1213
|
}
|