dsh-m 0.1.0 → 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.
@@ -1,89 +1,290 @@
1
1
  /**
2
- * registry(DESIGN.md §2):repo 内手工 curated registry.json
3
- * 分发顺序:源覆盖 jsDelivr @main raw @main → TTL 缓存 → 包内快照兜底。
2
+ * registry(DESIGN.md §2):官方 curated registry.json + 单地址自定义覆盖(整体覆盖,不合并)。
3
+ * Task 1:严格 v1 schema、地址解析、状态契约。Task 2:分源 loader、namespace/cacheKey v2
4
+ * 原子 cache、fd 级本地读取、candidate 与 active prune 分离、commitActiveSource。
4
5
  */
5
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { createHash, randomUUID } from 'node:crypto';
6
7
  import { createRequire } from 'node:module';
7
- import { join } from 'node:path';
8
+ import { isAbsolute, join, normalize } from 'node:path';
9
+ import { constants as fsConstants, mkdirSync } from 'node:fs';
10
+ import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
8
11
  import { cacheDir } from './env.js';
9
- import { fetchJsonLimited } from './httpx.js';
12
+ import { decodeUtf8Fatal, fetchJsonLimitedMeta } from './httpx.js';
10
13
  export const CATEGORIES = ['market', 'tools', 'ui', 'search', 'media', 'other'];
11
- const REPO = 'iasiv5/dsh-m';
12
- const DEFAULT_URLS = [
13
- { source: 'jsdelivr', url: `https://cdn.jsdelivr.net/gh/${REPO}@main/registry.json` },
14
- { source: 'raw', url: `https://raw.githubusercontent.com/${REPO}/main/registry.json` },
15
- ];
16
- // ---------- 校验 ----------
14
+ /** 容量上限:2 MiB 是原始 UTF-8 bytes(不是字符数);条目超限拒绝整份清单。 */
15
+ export const MAX_REGISTRY_BYTES = 2 * 1024 * 1024;
16
+ export const MAX_PLUGINS = 1000;
17
+ const MAX_ID = 64;
18
+ const MAX_NAME = 100;
19
+ const MAX_DESCRIPTION = 500;
20
+ const MAX_TAGS = 10;
21
+ const MAX_TAG = 30;
22
+ const MAX_NPM = 214;
23
+ const MAX_URL = 2048;
24
+ const GITHUB_OWNER_MAX = 39;
25
+ const GITHUB_REPO_MAX = 100;
26
+ const GITHUB_PATH_MAX = GITHUB_OWNER_MAX + 1 + GITHUB_REPO_MAX;
27
+ const ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
28
+ const NPM_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
17
29
  const GITHUB_RE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
18
- const HTTPS_URL_RE = /^https:\/\/\S+$/i;
30
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/;
31
+ /** 已知凭据 query key:registry URL 一律拒绝(大小写不敏感)。 */
32
+ const CREDENTIAL_QUERY_KEYS = new Set(['token', 'access_token', 'api_key', 'password', 'secret']);
33
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
34
+ export function registrySummary(state) {
35
+ return { isDefault: state.isDefault, status: state.status, stale: state.stale };
36
+ }
37
+ // ---------- 地址解析 ----------
38
+ const CACHE_KEY_VERSION = 'r1';
39
+ function stableKey(value) {
40
+ const hash = createHash('sha256').update(value).digest('hex').slice(0, 32);
41
+ return `${CACHE_KEY_VERSION}-${hash}`;
42
+ }
43
+ function normalizeLocalPath(raw) {
44
+ const p = normalize(raw);
45
+ if (!isAbsolute(p))
46
+ throw new Error('本地 registry 必须是绝对路径或 file:// URL');
47
+ if (p.endsWith('/'))
48
+ throw new Error('本地 registry 不能指向目录');
49
+ return p;
50
+ }
51
+ function parseFileUrl(input) {
52
+ let url;
53
+ try {
54
+ url = new URL(input);
55
+ }
56
+ catch {
57
+ throw new Error('无效的 file:// registry 地址');
58
+ }
59
+ if (url.protocol !== 'file:')
60
+ throw new Error('file registry 地址只支持 file:// 协议');
61
+ if (url.host !== '')
62
+ throw new Error('file:// registry 地址不允许携带 host');
63
+ let pathname;
64
+ try {
65
+ pathname = decodeURIComponent(url.pathname);
66
+ }
67
+ catch {
68
+ throw new Error('file:// registry 路径百分号编码无效');
69
+ }
70
+ if (CONTROL_RE.test(pathname))
71
+ throw new Error('registry 路径包含控制字符');
72
+ const normalized = normalizeLocalPath(pathname);
73
+ return { kind: 'file', input, normalized, cacheKey: stableKey(`file:${normalized}`) };
74
+ }
75
+ function parseHttpUrl(input) {
76
+ let url;
77
+ try {
78
+ url = new URL(input);
79
+ }
80
+ catch {
81
+ throw new Error('registry 地址不是合法 URL');
82
+ }
83
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
84
+ throw new Error('registry 远程地址只允许 HTTPS(loopback 可用 HTTP)');
85
+ }
86
+ const hostname = url.hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
87
+ if (url.protocol === 'http:' && !LOOPBACK_HOSTS.has(hostname)) {
88
+ throw new Error('HTTP registry 只允许 loopback(127.0.0.1 / localhost / ::1);外网请使用 HTTPS');
89
+ }
90
+ if (url.username || url.password)
91
+ throw new Error('registry URL 不允许携带 userinfo');
92
+ for (const key of url.searchParams.keys()) {
93
+ if (CREDENTIAL_QUERY_KEYS.has(key.toLowerCase())) {
94
+ throw new Error(`registry URL 查询参数不允许携带凭据字段(${key})`);
95
+ }
96
+ }
97
+ url.hash = '';
98
+ const normalized = url.toString();
99
+ return { kind: 'url', input, normalized, cacheKey: stableKey(`url:${normalized}`) };
100
+ }
101
+ export function parseRegistryAddress(raw) {
102
+ const input = typeof raw === 'string' ? raw.trim() : '';
103
+ if (input === '')
104
+ return { kind: 'default', input: '', normalized: '', cacheKey: 'default' };
105
+ if (CONTROL_RE.test(input))
106
+ throw new Error('registry 地址包含控制字符');
107
+ if (/^file:/i.test(input))
108
+ return parseFileUrl(input);
109
+ if (input.startsWith('/')) {
110
+ const normalized = normalizeLocalPath(input);
111
+ return { kind: 'file', input, normalized, cacheKey: stableKey(`file:${normalized}`) };
112
+ }
113
+ if (/^[a-z][a-z0-9+.-]*:/i.test(input))
114
+ return parseHttpUrl(input);
115
+ throw new Error('registry 地址必须是 HTTPS URL、loopback HTTP URL、绝对路径或 file:// 路径');
116
+ }
117
+ // ---------- 严格 v1 校验 ----------
118
+ const TOP_LEVEL_KEYS = new Set(['version', 'plugins']);
119
+ const ENTRY_KEYS = new Set(['id', 'name', 'description', 'category', 'tags', 'source', 'npm', 'github', 'homepage', 'icon']);
120
+ function httpsUrlError(raw) {
121
+ if (raw.length > MAX_URL)
122
+ return `超过 ${MAX_URL} 字符`;
123
+ if (CONTROL_RE.test(raw))
124
+ return '包含控制字符';
125
+ let url;
126
+ try {
127
+ url = new URL(raw);
128
+ }
129
+ catch {
130
+ return '不是合法 URL';
131
+ }
132
+ if (url.protocol !== 'https:')
133
+ return '只允许 HTTPS';
134
+ if (url.username || url.password)
135
+ return '不允许 userinfo';
136
+ return null;
137
+ }
19
138
  export function validateRegistry(raw) {
20
139
  const errors = [];
21
- if (!raw || typeof raw !== 'object')
140
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
22
141
  return { ok: false, errors: ['registry 根必须是对象'], registry: null };
142
+ }
23
143
  const obj = raw;
144
+ for (const key of Object.keys(obj)) {
145
+ if (!TOP_LEVEL_KEYS.has(key))
146
+ errors.push(`${key}: 未知字段(顶层只允许 version/plugins)`);
147
+ }
24
148
  if (obj.version !== 1)
25
- errors.push('version 必须为 1');
26
- if (!Array.isArray(obj.plugins))
27
- return { ok: false, errors: [...errors, 'plugins 必须是数组'], registry: null };
149
+ errors.push('version: 必须为 1');
150
+ if (!Array.isArray(obj.plugins)) {
151
+ errors.push('plugins: 必须是数组');
152
+ return { ok: false, errors, registry: null };
153
+ }
154
+ if (obj.plugins.length > MAX_PLUGINS) {
155
+ errors.push(`plugins: ${obj.plugins.length} 条超过上限 ${MAX_PLUGINS},拒绝整份清单`);
156
+ return { ok: false, errors, registry: null };
157
+ }
28
158
  const plugins = [];
29
159
  const seen = new Set();
30
160
  obj.plugins.forEach((item, index) => {
31
161
  const where = `plugins[${index}]`;
32
- if (!item || typeof item !== 'object') {
162
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
33
163
  errors.push(`${where}: 必须是对象`);
34
164
  return;
35
165
  }
36
166
  const e = item;
37
- const id = String(e.id || '').trim();
38
- if (!id || id.length > 64)
39
- return errors.push(`${where}.id 无效`);
40
- if (seen.has(id))
41
- return errors.push(`${where}.id 重复: ${id}`);
42
- seen.add(id);
43
- const category = String(e.category || '');
44
- if (!CATEGORIES.includes(category))
45
- return errors.push(`${where}.category 无效: ${category}`);
46
- const source = String(e.source || '');
47
- if (source !== 'npm' && source !== 'github')
48
- return errors.push(`${where}.source 无效: ${source}`);
49
- const entry = {
50
- id,
51
- name: String(e.name || id).trim().slice(0, 100),
52
- description: String(e.description || '').trim().slice(0, 500),
53
- category: category,
54
- tags: Array.isArray(e.tags) ? e.tags.map((t) => String(t).trim().slice(0, 30)).filter(Boolean).slice(0, 10) : [],
55
- source,
56
- };
57
- if (source === 'npm') {
58
- const npm = String(e.npm || '').trim();
59
- if (!npm)
60
- return errors.push(`${where}.npm 必填(source=npm)`);
61
- entry.npm = npm;
62
- }
63
- if (source === 'github') {
64
- const gh = String(e.github || '').trim();
65
- if (!GITHUB_RE.test(gh))
66
- return errors.push(`${where}.github 必须是 owner/repo(source=github)`);
67
- entry.github = gh;
68
- }
69
- if (e.npm && source !== 'npm')
70
- entry.npm = String(e.npm).trim();
71
- if (e.github && source !== 'github' && GITHUB_RE.test(String(e.github).trim()))
72
- entry.github = String(e.github).trim();
167
+ for (const key of Object.keys(e)) {
168
+ if (!ENTRY_KEYS.has(key))
169
+ errors.push(`${where}.${key}: 未知字段`);
170
+ }
171
+ const id = typeof e.id === 'string' ? e.id.trim() : '';
172
+ if (id === '')
173
+ errors.push(`${where}.id: 必填`);
174
+ else if (id.length > MAX_ID)
175
+ errors.push(`${where}.id: 超过 ${MAX_ID} 字符`);
176
+ else if (!ID_RE.test(id))
177
+ errors.push(`${where}.id: 须小写字母/数字开头,只含小写字母、数字、.、_、-`);
178
+ else if (seen.has(id))
179
+ errors.push(`${where}.id: 重复 ${id}`);
180
+ else
181
+ seen.add(id);
182
+ const name = typeof e.name === 'string' ? e.name.trim() : '';
183
+ if (name === '')
184
+ errors.push(`${where}.name: 必填`);
185
+ else if (name.length > MAX_NAME)
186
+ errors.push(`${where}.name: 超过 ${MAX_NAME} 字符`);
187
+ const description = typeof e.description === 'string' ? e.description.trim() : '';
188
+ if (description === '')
189
+ errors.push(`${where}.description: 必填`);
190
+ else if (description.length > MAX_DESCRIPTION)
191
+ errors.push(`${where}.description: 超过 ${MAX_DESCRIPTION} 字符`);
192
+ const category = e.category;
193
+ const categoryOk = typeof category === 'string' && CATEGORIES.includes(category);
194
+ if (!categoryOk)
195
+ errors.push(`${where}.category: 无效 ${JSON.stringify(category ?? null)}`);
196
+ let tags = [];
197
+ if (!Array.isArray(e.tags)) {
198
+ errors.push(`${where}.tags: 必须是字符串数组`);
199
+ }
200
+ else {
201
+ if (e.tags.length > MAX_TAGS)
202
+ errors.push(`${where}.tags: 超过 ${MAX_TAGS} 个`);
203
+ const seenTags = new Set();
204
+ e.tags.forEach((t, ti) => {
205
+ const ts = typeof t === 'string' ? t.trim() : '';
206
+ if (ts === '')
207
+ errors.push(`${where}.tags[${ti}]: 必须是非空字符串`);
208
+ else if (ts.length > MAX_TAG)
209
+ errors.push(`${where}.tags[${ti}]: 超过 ${MAX_TAG} 字符`);
210
+ else if (seenTags.has(ts))
211
+ errors.push(`${where}.tags[${ti}]: 重复 ${ts}`);
212
+ else {
213
+ seenTags.add(ts);
214
+ tags = [...tags, ts];
215
+ }
216
+ });
217
+ }
218
+ const source = e.source;
219
+ const sourceOk = source === 'npm' || source === 'github';
220
+ if (!sourceOk)
221
+ errors.push(`${where}.source: 必须是 npm 或 github`);
222
+ let entryNpm;
223
+ if (e.npm !== undefined) {
224
+ const nv = typeof e.npm === 'string' ? e.npm.trim() : '';
225
+ if (nv === '')
226
+ errors.push(`${where}.npm: 不能为空字符串`);
227
+ else if (nv.length > MAX_NPM)
228
+ errors.push(`${where}.npm: 超过 ${MAX_NPM} 字符`);
229
+ else if (!NPM_RE.test(nv))
230
+ errors.push(`${where}.npm: 不是合法 npm 包名(不接受版本/range/URL/空白)`);
231
+ else
232
+ entryNpm = nv;
233
+ }
234
+ let entryGithub;
235
+ if (e.github !== undefined) {
236
+ const gv = typeof e.github === 'string' ? e.github.trim() : '';
237
+ if (gv === '')
238
+ errors.push(`${where}.github: 不能为空字符串`);
239
+ else if (gv.length > GITHUB_PATH_MAX)
240
+ errors.push(`${where}.github: 超过 ${GITHUB_PATH_MAX} 字符(owner ≤39、repo ≤100)`);
241
+ else if (!GITHUB_RE.test(gv))
242
+ errors.push(`${where}.github: 必须是 owner/repo`);
243
+ else {
244
+ const [owner = '', repo = ''] = gv.split('/');
245
+ if (owner.length > GITHUB_OWNER_MAX || repo.length > GITHUB_REPO_MAX) {
246
+ errors.push(`${where}.github: owner ≤${GITHUB_OWNER_MAX}、repo ≤${GITHUB_REPO_MAX}`);
247
+ }
248
+ else
249
+ entryGithub = gv;
250
+ }
251
+ }
252
+ if (source === 'npm' && entryNpm === undefined)
253
+ errors.push(`${where}.npm: source=npm 必填`);
254
+ if (source === 'github' && entryGithub === undefined)
255
+ errors.push(`${where}.github: source=github 必填 owner/repo`);
256
+ let entryHomepage;
257
+ let entryIcon;
73
258
  for (const key of ['homepage', 'icon']) {
74
259
  const v = e[key];
75
- if (v === undefined || v === null || v === '')
260
+ if (v === undefined)
76
261
  continue;
77
- const s = String(v).trim();
78
- if (!HTTPS_URL_RE.test(s))
79
- return errors.push(`${where}.${key} 必须是 https URL`);
80
- entry[key] = s;
262
+ const s = typeof v === 'string' ? v.trim() : '';
263
+ const problem = s === '' ? '不能为空字符串' : httpsUrlError(s);
264
+ if (problem)
265
+ errors.push(`${where}.${key}: ${problem}`);
266
+ else if (key === 'homepage')
267
+ entryHomepage = s;
268
+ else
269
+ entryIcon = s;
81
270
  }
82
- plugins.push(entry);
271
+ // 错误条目不产生合法输出:整份清单在存在任何错误时返回 null
272
+ plugins.push({
273
+ id,
274
+ name,
275
+ description,
276
+ category: category,
277
+ tags,
278
+ source: source,
279
+ ...(entryNpm !== undefined ? { npm: entryNpm } : {}),
280
+ ...(entryGithub !== undefined ? { github: entryGithub } : {}),
281
+ ...(entryHomepage !== undefined ? { homepage: entryHomepage } : {}),
282
+ ...(entryIcon !== undefined ? { icon: entryIcon } : {}),
283
+ });
83
284
  });
84
285
  return { ok: errors.length === 0, errors, registry: errors.length === 0 ? { version: 1, plugins } : null };
85
286
  }
86
- // ---------- 包内快照兜底 ----------
287
+ // ---------- 包内快照兜底(default 专属,custom 永不使用) ----------
87
288
  function bundledSnapshot() {
88
289
  const require = createRequire(import.meta.url);
89
290
  for (const rel of ['../registry.json', '../../registry.json']) {
@@ -99,27 +300,115 @@ function bundledSnapshot() {
99
300
  }
100
301
  return { version: 1, plugins: [] };
101
302
  }
102
- function cachePath() {
103
- return join(cacheDir(), 'registry.json');
104
- }
105
- function readCache() {
303
+ export async function readRegistryFile(path, hooks = {}) {
304
+ const real = await realpath(path);
305
+ // realpath 之后仍可能被换成 symlink:O_NOFOLLOW 在最终路径上兜底
306
+ const fh = await open(real, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
106
307
  try {
107
- const raw = JSON.parse(readFileSync(cachePath(), 'utf8'));
108
- if (!raw || typeof raw !== 'object' || !raw.registry || !Array.isArray(raw.registry.plugins))
109
- return null;
110
- return raw;
308
+ const st = await fh.stat();
309
+ if (!st.isFile())
310
+ throw new Error('本地 registry 不是普通文件');
311
+ if (st.size > MAX_REGISTRY_BYTES)
312
+ throw new Error(`本地 registry 超过 ${MAX_REGISTRY_BYTES} 字节上限`);
313
+ await hooks.afterStat?.();
314
+ const chunks = [];
315
+ let total = 0;
316
+ const buf = Buffer.alloc(64 * 1024);
317
+ for (;;) {
318
+ const { bytesRead } = await fh.read(buf, 0, buf.length, null);
319
+ if (bytesRead === 0)
320
+ break;
321
+ total += bytesRead;
322
+ if (total > MAX_REGISTRY_BYTES)
323
+ throw new Error(`本地 registry 读取超过 ${MAX_REGISTRY_BYTES} 字节上限`);
324
+ chunks.push(Buffer.from(buf.subarray(0, bytesRead)));
325
+ }
326
+ const st2 = await fh.stat();
327
+ if (!st2.isFile() || st2.ino !== st.ino || st2.size !== total) {
328
+ throw new Error('本地 registry 读取期间发生变化,已拒绝本次内容');
329
+ }
330
+ return Buffer.concat(chunks);
111
331
  }
112
- catch {
113
- return null;
332
+ finally {
333
+ await fh.close().catch(() => undefined);
114
334
  }
115
335
  }
116
- function writeCache(file) {
336
+ const CACHE_FILE_VERSION = 2;
337
+ const DEFAULT_CACHE_KEY = 'default';
338
+ const ACTIVE_SOURCE_FILE = 'active-source.json';
339
+ function nsDir(namespace) {
340
+ return join(cacheDir(), namespace);
341
+ }
342
+ function cacheFilePath(namespace, cacheKey) {
343
+ return join(nsDir(namespace), `${cacheKey}.json`);
344
+ }
345
+ function cacheDirSyncMode() {
346
+ mkdirSync(cacheDir(), { recursive: true, mode: 0o700 });
347
+ }
348
+ /** 同 key 写锁:进程内串行化同一 cache 文件的写入。 */
349
+ const writeLocks = new Map();
350
+ function withKeyLock(key, task) {
351
+ const prev = writeLocks.get(key) ?? Promise.resolve();
352
+ const next = prev.then(task, task);
353
+ const tail = next.catch(() => undefined);
354
+ writeLocks.set(key, tail);
355
+ void tail.finally(() => {
356
+ if (writeLocks.get(key) === tail)
357
+ writeLocks.delete(key);
358
+ });
359
+ return next;
360
+ }
361
+ /** 原子写:0600 临时文件(O_CREAT|O_EXCL)→ fsync → rename。目标为 symlink 时拒绝。 */
362
+ async function atomicWriteJson(target, dir, value) {
363
+ return withKeyLock(target, async () => {
364
+ try {
365
+ await mkdir(dir, { recursive: true, mode: 0o700 });
366
+ cacheDirSyncMode();
367
+ let existing = null;
368
+ try {
369
+ existing = await lstat(target);
370
+ }
371
+ catch {
372
+ /* not exists */
373
+ }
374
+ if (existing && existing.isSymbolicLink())
375
+ return false;
376
+ const tmp = join(dir, `.${target.split('/').pop() ?? 'cache'}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`);
377
+ const fh = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
378
+ try {
379
+ await fh.write(Buffer.from(JSON.stringify(value, null, 2), 'utf8'));
380
+ await fh.sync();
381
+ }
382
+ finally {
383
+ await fh.close().catch(() => undefined);
384
+ }
385
+ await rename(tmp, target);
386
+ return true;
387
+ }
388
+ catch {
389
+ return false;
390
+ }
391
+ });
392
+ }
393
+ async function writeCacheFile(file) {
394
+ return atomicWriteJson(cacheFilePath(file.namespace, file.cacheKey), nsDir(file.namespace), file);
395
+ }
396
+ async function readCacheFile(namespace, cacheKey) {
117
397
  try {
118
- mkdirSync(cacheDir(), { recursive: true });
119
- writeFileSync(cachePath(), JSON.stringify(file, null, 2));
398
+ const raw = JSON.parse(await readFile(cacheFilePath(namespace, cacheKey), 'utf8'));
399
+ if (!raw || typeof raw !== 'object')
400
+ return null;
401
+ if (raw.version !== CACHE_FILE_VERSION || raw.namespace !== namespace || raw.cacheKey !== cacheKey)
402
+ return null;
403
+ if (typeof raw.fetchedAt !== 'string' || !raw.registry || !Array.isArray(raw.registry.plugins))
404
+ return null;
405
+ const re = validateRegistry(raw.registry);
406
+ if (!re.ok || !re.registry)
407
+ return null;
408
+ return { ...raw, registry: re.registry };
120
409
  }
121
410
  catch {
122
- /* 缓存写失败不致命 */
411
+ return null;
123
412
  }
124
413
  }
125
414
  function cacheFresh(file, ttlMin) {
@@ -128,38 +417,285 @@ function cacheFresh(file, ttlMin) {
128
417
  return false;
129
418
  return Date.now() - t < ttlMin * 60_000;
130
419
  }
420
+ /** 清理 namespace 内非 default、非当前 custom 的 cache 文件(不动 metadata)。 */
421
+ async function pruneCaches(namespace, keepCustomKey) {
422
+ try {
423
+ const dir = nsDir(namespace);
424
+ const entries = await readdir(dir).catch(() => []);
425
+ const keep = new Set([`${DEFAULT_CACHE_KEY}.json`, ACTIVE_SOURCE_FILE]);
426
+ if (keepCustomKey)
427
+ keep.add(`${keepCustomKey}.json`);
428
+ for (const name of entries) {
429
+ if (keep.has(name) || !name.endsWith('.json'))
430
+ continue;
431
+ await rm(join(dir, name), { force: true }).catch(() => {
432
+ throw new Error(`无法删除 ${name}`);
433
+ });
434
+ }
435
+ return true;
436
+ }
437
+ catch {
438
+ return false;
439
+ }
440
+ }
441
+ /**
442
+ * settings update 成功后由 controller 调用:先写 accepted metadata(仅 host namespace),
443
+ * 再 prune 非当前 custom cache。metadata 失败 → 不 prune;prune 失败 → 保留旧 cache;
444
+ * 都只返回 warning,不反向撤销已成功的 settings update。
445
+ */
446
+ export async function commitActiveSource(address, namespace) {
447
+ let metadataCommitted = true;
448
+ if (namespace === 'host') {
449
+ const meta = {
450
+ version: 1,
451
+ namespace: 'host',
452
+ configuredAddress: address.normalized,
453
+ cacheKey: address.cacheKey,
454
+ savedAt: new Date().toISOString(),
455
+ };
456
+ metadataCommitted = await atomicWriteJson(join(nsDir('host'), ACTIVE_SOURCE_FILE), nsDir('host'), meta);
457
+ }
458
+ if (!metadataCommitted) {
459
+ return { metadataCommitted: false, pruned: false, warning: 'accepted-source 元数据写入失败,已跳过旧 cache 清理(新配置仍生效)' };
460
+ }
461
+ const pruned = await pruneCaches(namespace, address.kind === 'default' ? null : address.cacheKey);
462
+ if (!pruned) {
463
+ return { metadataCommitted: true, pruned: false, warning: '旧来源 cache 清理失败(不影响新配置生效)' };
464
+ }
465
+ return { metadataCommitted: true, pruned: true, warning: null };
466
+ }
131
467
  // ---------- 加载 ----------
132
- export async function loadRegistry(cfg = {}, opts = {}) {
133
- const errors = [];
134
- const timeoutMs = cfg.timeoutMs ?? 20_000;
135
- const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
136
- const cached = readCache();
137
- if (!opts.force && cached && cacheFresh(cached, ttlMin)) {
138
- return { registry: cached.registry, source: 'cache', fetchedAt: cached.fetchedAt, errors };
468
+ function buildState(params) {
469
+ return {
470
+ configuredAddress: params.configuredAddress,
471
+ activeAddress: params.activeAddress,
472
+ source: params.source,
473
+ status: params.status,
474
+ isDefault: params.configuredAddress === '',
475
+ stale: params.status === 'stale',
476
+ fetchedAt: params.fetchedAt,
477
+ errors: params.errors,
478
+ count: params.count,
479
+ };
480
+ }
481
+ const REPO = 'iasiv5/dsh-m';
482
+ // raw 优先:jsDelivr 对 @main 有 CDN 缓存(可 stale 数小时),raw 始终反映 main 最新内容;
483
+ // jsDelivr 降为备用线路(可通过 purge.jsdelivr.net 手动清缓存)。
484
+ const DEFAULT_URLS = [
485
+ { source: 'default-raw', url: `https://raw.githubusercontent.com/${REPO}/main/registry.json` },
486
+ { source: 'default-jsdelivr', url: `https://cdn.jsdelivr.net/gh/${REPO}@main/registry.json` },
487
+ ];
488
+ function attemptTimeoutMs(cfg, opts, startedAt) {
489
+ const per = cfg.timeoutMs ?? 20_000;
490
+ if (!opts.deadlineMs || !Number.isFinite(opts.deadlineMs))
491
+ return per;
492
+ const remaining = opts.deadlineMs - (Date.now() - startedAt);
493
+ return Math.max(1, Math.min(per, remaining));
494
+ }
495
+ function errorMessage(err) {
496
+ const he = err;
497
+ if (he && typeof he.status === 'number')
498
+ return `HTTP ${he.status}`;
499
+ return err instanceof Error ? err.message : String(err);
500
+ }
501
+ async function fetchRegistryFromUrl(url, timeoutMs, signal) {
502
+ const { data, finalUrl } = await fetchJsonLimitedMeta(url, { timeoutMs, maxBytes: MAX_REGISTRY_BYTES, signal });
503
+ const parsed = validateRegistry(data);
504
+ if (!parsed.ok || !parsed.registry) {
505
+ throw new Error(`registry 校验失败 — ${parsed.errors.slice(0, 3).join('; ')}`);
506
+ }
507
+ return { registry: parsed.registry, finalUrl };
508
+ }
509
+ async function readRegistryFromPath(path) {
510
+ const buf = await readRegistryFile(path);
511
+ let data;
512
+ try {
513
+ data = JSON.parse(decodeUtf8Fatal(buf));
139
514
  }
140
- const candidates = [];
141
- if (cfg.registryUrl && cfg.registryUrl.trim()) {
142
- candidates.push({ source: 'override', url: cfg.registryUrl.trim() });
515
+ catch (err) {
516
+ throw new Error(err instanceof Error && /UTF-8|decode/i.test(err.message) ? '本地 registry 不是合法 UTF-8' : '本地 registry 不是合法 JSON');
143
517
  }
144
- candidates.push(...DEFAULT_URLS);
145
- for (const candidate of candidates) {
518
+ const parsed = validateRegistry(data);
519
+ if (!parsed.ok || !parsed.registry) {
520
+ throw new Error(`registry 校验失败 — ${parsed.errors.slice(0, 3).join('; ')}`);
521
+ }
522
+ return parsed.registry;
523
+ }
524
+ function loadedFromCacheFile(file, configuredAddress, errors) {
525
+ // cache 命中是“stale 供应”:source 报告 cache 来源,而不是当初抓取用的 raw/jsdelivr/url/file
526
+ const source = file.source.startsWith('custom') ? 'custom-cache' : 'default-cache';
527
+ return {
528
+ ...buildState({
529
+ configuredAddress,
530
+ activeAddress: file.activeAddress,
531
+ source,
532
+ status: 'stale',
533
+ fetchedAt: file.fetchedAt,
534
+ errors,
535
+ count: file.registry.plugins.length,
536
+ }),
537
+ registry: file.registry,
538
+ };
539
+ }
540
+ /** default 链:raw → jsDelivr → default cache →(可选)bundled。从不 prune。 */
541
+ async function loadDefaultChain(cfg, opts, chain) {
542
+ const errors = [];
543
+ const startedAt = Date.now();
544
+ for (const candidate of DEFAULT_URLS) {
146
545
  try {
147
- const raw = await fetchJsonLimited(candidate.url, { timeoutMs });
148
- const parsed = validateRegistry(raw);
149
- if (!parsed.ok || !parsed.registry) {
150
- errors.push(`${candidate.source}: registry 校验失败 — ${parsed.errors.slice(0, 3).join('; ')}`);
151
- continue;
152
- }
546
+ const { registry, finalUrl } = await fetchRegistryFromUrl(candidate.url, attemptTimeoutMs(cfg, opts, startedAt), opts.signal);
153
547
  const fetchedAt = new Date().toISOString();
154
- writeCache({ fetchedAt, source: candidate.source, registry: parsed.registry });
155
- return { registry: parsed.registry, source: candidate.source, fetchedAt, errors };
548
+ await writeCacheFile({
549
+ version: CACHE_FILE_VERSION,
550
+ namespace: chain.namespace,
551
+ cacheKey: DEFAULT_CACHE_KEY,
552
+ configuredAddress: '',
553
+ activeAddress: finalUrl,
554
+ source: candidate.source,
555
+ fetchedAt,
556
+ registry,
557
+ });
558
+ return {
559
+ ...buildState({
560
+ configuredAddress: '',
561
+ activeAddress: finalUrl,
562
+ source: candidate.source,
563
+ status: 'ready',
564
+ fetchedAt,
565
+ errors,
566
+ count: registry.plugins.length,
567
+ }),
568
+ registry,
569
+ };
156
570
  }
157
571
  catch (err) {
158
- const he = err;
159
- errors.push(`${candidate.source}: ${he?.status ? `HTTP ${he.status}` : err instanceof Error ? err.message : String(err)}`);
572
+ errors.push(`${candidate.source}: ${errorMessage(err)}`);
160
573
  }
161
574
  }
575
+ const cached = await readCacheFile(chain.namespace, DEFAULT_CACHE_KEY);
162
576
  if (cached)
163
- return { registry: cached.registry, source: 'cache', fetchedAt: cached.fetchedAt, errors };
164
- return { registry: bundledSnapshot(), source: 'bundled', fetchedAt: null, errors };
577
+ return loadedFromCacheFile(cached, '', errors);
578
+ if (chain.includeBundled) {
579
+ const bundled = bundledSnapshot();
580
+ return {
581
+ ...buildState({
582
+ configuredAddress: '',
583
+ activeAddress: null,
584
+ source: 'bundled',
585
+ status: 'stale',
586
+ fetchedAt: null,
587
+ errors,
588
+ count: bundled.plugins.length,
589
+ }),
590
+ registry: bundled,
591
+ };
592
+ }
593
+ return {
594
+ ...buildState({
595
+ configuredAddress: '',
596
+ activeAddress: null,
597
+ source: 'bundled',
598
+ status: 'unavailable',
599
+ fetchedAt: null,
600
+ errors,
601
+ count: 0,
602
+ }),
603
+ registry: { version: 1, plugins: [] },
604
+ };
605
+ }
606
+ /** custom 链:只尝试该 source 自身,失败只读同 sourceKey cache;没有数据 → unavailable。 */
607
+ async function loadCustomChain(address, cfg, opts, chain) {
608
+ const errors = [];
609
+ const source = address.kind === 'url' ? 'custom-url' : 'custom-file';
610
+ const startedAt = Date.now();
611
+ try {
612
+ const result = address.kind === 'url'
613
+ ? await fetchRegistryFromUrl(address.normalized, attemptTimeoutMs(cfg, opts, startedAt), opts.signal)
614
+ : { registry: await readRegistryFromPath(address.normalized), finalUrl: address.normalized };
615
+ const fetchedAt = new Date().toISOString();
616
+ await writeCacheFile({
617
+ version: CACHE_FILE_VERSION,
618
+ namespace: chain.namespace,
619
+ cacheKey: address.cacheKey,
620
+ configuredAddress: address.normalized,
621
+ activeAddress: result.finalUrl,
622
+ source,
623
+ fetchedAt,
624
+ registry: result.registry,
625
+ });
626
+ return {
627
+ ...buildState({
628
+ configuredAddress: address.normalized,
629
+ activeAddress: result.finalUrl,
630
+ source,
631
+ status: 'ready',
632
+ fetchedAt,
633
+ errors,
634
+ count: result.registry.plugins.length,
635
+ }),
636
+ registry: result.registry,
637
+ };
638
+ }
639
+ catch (err) {
640
+ errors.push(`${source}: ${errorMessage(err)}`);
641
+ }
642
+ if (chain.allowCacheFallback) {
643
+ const cached = await readCacheFile(chain.namespace, address.cacheKey);
644
+ if (cached)
645
+ return loadedFromCacheFile(cached, address.normalized, errors);
646
+ }
647
+ return {
648
+ ...buildState({
649
+ configuredAddress: address.normalized,
650
+ activeAddress: null,
651
+ source: 'custom-unavailable',
652
+ status: 'unavailable',
653
+ fetchedAt: null,
654
+ errors,
655
+ count: 0,
656
+ }),
657
+ registry: { version: 1, plugins: [] },
658
+ };
659
+ }
660
+ /** active 读取:default/custom 各自 fallback;网络成功后可在本 namespace 内 prune。 */
661
+ export async function loadRegistry(cfg = {}, opts = {}) {
662
+ const namespace = opts.namespace ?? 'host';
663
+ const address = parseRegistryAddress(cfg.registryUrl);
664
+ const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
665
+ if (!opts.force) {
666
+ const cacheKey = address.kind === 'default' ? DEFAULT_CACHE_KEY : address.cacheKey;
667
+ const cached = await readCacheFile(namespace, cacheKey);
668
+ if (cached && cacheFresh(cached, ttlMin))
669
+ return loadedFromCacheFile(cached, address.normalized, []);
670
+ }
671
+ if (address.kind === 'default') {
672
+ const loaded = await loadDefaultChain(cfg, opts, { namespace, includeBundled: true });
673
+ if (opts.prune !== false && loaded.status === 'ready')
674
+ await pruneCaches(namespace, null);
675
+ return loaded;
676
+ }
677
+ const loaded = await loadCustomChain(address, cfg, opts, { namespace, allowCacheFallback: true });
678
+ if (opts.prune !== false && loaded.status === 'ready')
679
+ await pruneCaches(namespace, address.cacheKey);
680
+ return loaded;
681
+ }
682
+ /** candidate 读取:apply 前校验专用。只写候选 cache、绝不 prune;default 不落 bundled。 */
683
+ export async function loadRegistryCandidate(cfg, opts = {}) {
684
+ const namespace = opts.namespace ?? 'host';
685
+ const address = parseRegistryAddress(cfg.registryUrl);
686
+ if (address.kind === 'default') {
687
+ return loadDefaultChain(cfg, opts, { namespace, includeBundled: false });
688
+ }
689
+ return loadCustomChain(address, cfg, opts, { namespace, allowCacheFallback: true });
690
+ }
691
+ /** default 显式加载(下载/主动刷新)。写 default cache,从不 prune。 */
692
+ export async function loadDefaultRegistry(cfg = {}, opts = {}) {
693
+ const namespace = opts.namespace ?? 'host';
694
+ const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
695
+ if (!opts.force) {
696
+ const cached = await readCacheFile(namespace, DEFAULT_CACHE_KEY);
697
+ if (cached && cacheFresh(cached, ttlMin))
698
+ return loadedFromCacheFile(cached, '', []);
699
+ }
700
+ return loadDefaultChain(cfg, opts, { namespace, includeBundled: true });
165
701
  }