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.
@@ -9,7 +9,85 @@ import { spawn } from 'node:child_process';
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
10
  import { dirname, isAbsolute, join, resolve } from 'node:path';
11
11
  import { installTimeoutMs, WEB_PROFILE, webProfileDir } from './env.js';
12
+ import { createProgressTracker } from './progress.js';
12
13
  const TARGET_RE = /^[A-Za-z0-9@:./_#+-]+$/;
14
+ const NDJSON_COMMANDS = new Set(['add', 'remove', 'install']);
15
+ export const BOOT_ID = `${String(process.pid)}-${String(Date.now())}`;
16
+ export const progress = {
17
+ active: false,
18
+ target: '',
19
+ startedAt: 0,
20
+ lastLine: '',
21
+ phase: null,
22
+ done: 0,
23
+ total: null,
24
+ currentPackage: null,
25
+ downloaded: null,
26
+ size: null,
27
+ ndjson: false,
28
+ error: null,
29
+ };
30
+ export function publicInstallStatus() {
31
+ return {
32
+ active: progress.active,
33
+ target: progress.target,
34
+ seconds: progress.active ? Math.round((Date.now() - progress.startedAt) / 1000) : 0,
35
+ lastLine: progress.lastLine,
36
+ phase: progress.phase,
37
+ done: progress.done,
38
+ total: progress.total,
39
+ currentPackage: progress.currentPackage,
40
+ downloaded: progress.downloaded,
41
+ size: progress.size,
42
+ ndjson: progress.ndjson,
43
+ error: progress.error,
44
+ boot: BOOT_ID,
45
+ };
46
+ }
47
+ function beginProgress(target) {
48
+ progress.active = true;
49
+ progress.target = target;
50
+ progress.startedAt = Date.now();
51
+ progress.lastLine = '';
52
+ progress.phase = null;
53
+ progress.done = 0;
54
+ progress.total = null;
55
+ progress.currentPackage = null;
56
+ progress.downloaded = null;
57
+ progress.size = null;
58
+ progress.ndjson = false;
59
+ progress.error = null;
60
+ return createProgressTracker();
61
+ }
62
+ function makeProgressFeeder(tracker) {
63
+ let lineBuffer = '';
64
+ return (chunk) => {
65
+ lineBuffer += chunk;
66
+ let nl;
67
+ while ((nl = lineBuffer.indexOf('\n')) !== -1) {
68
+ const line = lineBuffer.slice(0, nl);
69
+ lineBuffer = lineBuffer.slice(nl + 1);
70
+ const trimmed = line.trim();
71
+ if (trimmed === '')
72
+ continue;
73
+ tracker.feed(trimmed);
74
+ if (!trimmed.startsWith('{'))
75
+ progress.lastLine = trimmed.slice(0, 200);
76
+ }
77
+ };
78
+ }
79
+ function syncProgress(tracker) {
80
+ const snap = tracker.snapshot;
81
+ progress.phase = snap.phase;
82
+ progress.done = snap.done;
83
+ progress.total = snap.total;
84
+ progress.currentPackage = snap.currentPackage;
85
+ progress.downloaded = snap.downloaded;
86
+ progress.size = snap.size;
87
+ progress.ndjson = snap.seen;
88
+ if (snap.error !== null)
89
+ progress.error = snap.error;
90
+ }
13
91
  export function webProfileName() {
14
92
  return WEB_PROFILE;
15
93
  }
@@ -45,6 +123,14 @@ export function pluginArgsFor(profileDirectory, pluginArgs) {
45
123
  return args;
46
124
  return [args[0], '-w', ...args.slice(1)];
47
125
  }
126
+ /** add/remove/install 追加 ndjson reporter,供进度解析(人类回退行由 feeder 记录 lastLine)。 */
127
+ export function preparePluginArgs(profileDirectory, pluginArgs) {
128
+ const args = pluginArgsFor(profileDirectory, pluginArgs);
129
+ const command = args[0];
130
+ if (command !== undefined && NDJSON_COMMANDS.has(command))
131
+ return [...args, '--reporter=ndjson'];
132
+ return args;
133
+ }
48
134
  export function isPrepareBlocked(text) {
49
135
  return /needs to execute build scripts|allowBuilds|ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED|ERR_PNPM_IGNORED_BUILDS/i.test(text);
50
136
  }
@@ -134,10 +220,14 @@ export async function runCommand(command, args, options) {
134
220
  };
135
221
  options.signal?.addEventListener('abort', onAbort, { once: true });
136
222
  child.stdout?.on('data', (chunk) => {
137
- out = (out + chunk.toString()).slice(-256 * 1024);
223
+ const text = chunk.toString();
224
+ out = (out + text).slice(-256 * 1024);
225
+ options.onChunk?.(text);
138
226
  });
139
227
  child.stderr?.on('data', (chunk) => {
140
- out = (out + chunk.toString()).slice(-256 * 1024);
228
+ const text = chunk.toString();
229
+ out = (out + text).slice(-256 * 1024);
230
+ options.onChunk?.(text);
141
231
  });
142
232
  child.on('error', (err) => finish(err));
143
233
  child.on('close', (code) => {
@@ -155,15 +245,33 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
155
245
  if (!isSafePluginTarget(target))
156
246
  throw new Error(`拒绝不安全的安装目标: ${target}`);
157
247
  const argv = (deps.dshArgv ?? dshArgv)();
158
- const prepared = pluginArgsFor(deps.profileDir ?? webProfileDir(), pluginArgs);
248
+ const prepared = preparePluginArgs(deps.profileDir ?? webProfileDir(), pluginArgs);
249
+ const tracker = beginProgress(target);
250
+ const feed = makeProgressFeeder(tracker);
159
251
  const run = deps.runCommand ?? runCommand;
160
- return run(argv.file, [...argv.args, 'plugin', '--profile', profile, ...prepared], {
161
- cwd: argv.cwd,
162
- timeoutMs: deps.timeoutMs ?? installTimeoutMs(),
163
- env: { CI: 'true' },
164
- viaShell: argv.viaShell,
165
- detached: process.platform !== 'win32',
166
- });
252
+ try {
253
+ return await run(argv.file, [...argv.args, 'plugin', '--profile', profile, ...prepared], {
254
+ cwd: argv.cwd,
255
+ timeoutMs: deps.timeoutMs ?? installTimeoutMs(),
256
+ env: { CI: 'true' },
257
+ viaShell: argv.viaShell,
258
+ detached: process.platform !== 'win32',
259
+ onChunk: (text) => {
260
+ feed(text);
261
+ syncProgress(tracker);
262
+ },
263
+ });
264
+ }
265
+ catch (err) {
266
+ // 失败/超时时也把原因写入状态端点,轮询侧能看到错误终态
267
+ const text = err instanceof Error ? err.message : String(err);
268
+ if (progress.error === null)
269
+ progress.error = text.slice(0, 800);
270
+ throw err;
271
+ }
272
+ finally {
273
+ progress.active = false;
274
+ }
167
275
  }
168
276
  /**
169
277
  * 安装。返回 usedAllowAllBuilds 供 UI 明确报告「该插件执行了构建脚本」。
@@ -0,0 +1,285 @@
1
+ /**
2
+ * /dshm Host API dispatcher(DESIGN.md §4/§5):可注入的 method 分发 + 请求防护 +
3
+ * HTTP 状态映射,供 host.ts 与契约测试共用。
4
+ *
5
+ * 解析顺序固定:只接受 POST → JSON Content-Type → 有上限读取并 drain body →
6
+ * 顶层必须是非 null/非数组对象且有 method → `ping` 跳过 guard,否则
7
+ * trustedRestartRequest host-equivalence guard → typed method/业务错误映射 → 其他 500。
8
+ */
9
+ import { BOOT_ID, addDshPlugin, publicInstallStatus } from './dsh-cli.js';
10
+ import { listInstalledWithMeta, listMarket, installFromRegistry, uninstallPlugin, upgradePlugin, } from './market.js';
11
+ import { readInstalledPluginReadme } from './installed.js';
12
+ import { isNewerVersion, npmLatest } from './versions.js';
13
+ import { RegistryConfigError } from './registry-controller.js';
14
+ import { checkRegistryEntries } from './registry-check.js';
15
+ import { CATEGORIES } from './registry.js';
16
+ import { servingPort, scheduleRestart, trustedRestartRequest } from './restart.js';
17
+ export class BadJsonError extends Error {
18
+ }
19
+ export class BodyTooLargeError extends Error {
20
+ }
21
+ export class UnsupportedMediaTypeError extends Error {
22
+ }
23
+ class ApiProtocolError extends Error {
24
+ status;
25
+ constructor(status, message) {
26
+ super(message);
27
+ this.status = status;
28
+ }
29
+ }
30
+ const BODY_MAX_BYTES = 1 << 20;
31
+ function readBody(req, maxBytes = BODY_MAX_BYTES) {
32
+ return new Promise((resolve, reject) => {
33
+ const chunks = [];
34
+ let size = 0;
35
+ let settled = false;
36
+ req.on('data', (chunk) => {
37
+ if (settled)
38
+ return;
39
+ size += chunk.length;
40
+ if (size > maxBytes) {
41
+ settled = true;
42
+ req.resume(); // drain:停止收集但不盲目 destroy socket
43
+ reject(new BodyTooLargeError());
44
+ return;
45
+ }
46
+ chunks.push(chunk);
47
+ });
48
+ req.on('end', () => {
49
+ if (!settled) {
50
+ settled = true;
51
+ resolve(Buffer.concat(chunks));
52
+ }
53
+ });
54
+ req.on('error', (err) => {
55
+ if (!settled) {
56
+ settled = true;
57
+ reject(err);
58
+ }
59
+ });
60
+ });
61
+ }
62
+ async function parseRequest(req) {
63
+ if ((req.method || 'GET').toUpperCase() !== 'POST') {
64
+ throw new ApiProtocolError(405, '只接受 POST');
65
+ }
66
+ const contentType = String(req.headers['content-type'] ?? '').trim();
67
+ if (!/^application\/(?:[\w.+-]+\+)?json\b/i.test(contentType)) {
68
+ throw new UnsupportedMediaTypeError();
69
+ }
70
+ const raw = await readBody(req);
71
+ let parsed;
72
+ if (raw.length === 0)
73
+ throw new BadJsonError('请求体不能为空');
74
+ try {
75
+ parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw));
76
+ }
77
+ catch {
78
+ throw new BadJsonError('请求体不是合法 JSON');
79
+ }
80
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
81
+ throw new BadJsonError('请求体顶层必须是对象');
82
+ }
83
+ const body = parsed;
84
+ const method = typeof body.method === 'string' ? body.method.trim() : '';
85
+ if (!method)
86
+ throw new BadJsonError('缺少 method');
87
+ if (method !== 'ping' && !trustedRestartRequest(req)) {
88
+ throw new ApiProtocolError(403, '拒绝跨源请求');
89
+ }
90
+ return { method, body };
91
+ }
92
+ function strArg(body, key) {
93
+ const v = body[key];
94
+ return typeof v === 'string' ? v.trim() : '';
95
+ }
96
+ function boolArg(v) {
97
+ return v === true || v === 'true' || v === 1 || v === '1';
98
+ }
99
+ function snapshotPayload(snap) {
100
+ return {
101
+ registryUrl: snap.configuredAddress,
102
+ configuredAddress: snap.configuredAddress,
103
+ activeConfigAddress: snap.activeConfigAddress,
104
+ pendingAddress: snap.pendingAddress,
105
+ configStatus: snap.configStatus,
106
+ configErrors: snap.configErrors,
107
+ warnings: snap.warnings,
108
+ registryState: snap.loaded,
109
+ };
110
+ }
111
+ function sendJson(res, status, value) {
112
+ const body = JSON.stringify(value);
113
+ res.writeHead(status, {
114
+ 'content-type': 'application/json; charset=utf-8',
115
+ 'content-length': Buffer.byteLength(body),
116
+ 'cache-control': 'no-store',
117
+ });
118
+ res.end(body);
119
+ }
120
+ function errorStatus(err) {
121
+ if (err instanceof BadJsonError)
122
+ return { status: 400, payload: { ok: false, error: err.message } };
123
+ if (err instanceof BodyTooLargeError)
124
+ return { status: 413, payload: { ok: false, error: '请求体过大' } };
125
+ if (err instanceof UnsupportedMediaTypeError)
126
+ return { status: 415, payload: { ok: false, error: 'Content-Type 必须是 application/json' } };
127
+ if (err instanceof RegistryConfigError) {
128
+ return { status: 422, payload: { ok: false, error: err.message, errors: err.errors } };
129
+ }
130
+ if (err instanceof ApiProtocolError)
131
+ return { status: err.status, payload: { ok: false, error: err.message } };
132
+ return { status: 500, payload: { ok: false, error: err instanceof Error ? err.message : String(err) } };
133
+ }
134
+ export function createApiDispatcher(ctx) {
135
+ const d = {
136
+ listMarket: ctx.deps?.listMarket ?? listMarket,
137
+ listInstalledWithMeta: ctx.deps?.listInstalledWithMeta ?? listInstalledWithMeta,
138
+ installFromRegistry: ctx.deps?.installFromRegistry ?? installFromRegistry,
139
+ uninstallPlugin: ctx.deps?.uninstallPlugin ?? uninstallPlugin,
140
+ upgradePlugin: ctx.deps?.upgradePlugin ?? upgradePlugin,
141
+ checkRegistryEntries: ctx.deps?.checkRegistryEntries ?? checkRegistryEntries,
142
+ npmLatest: ctx.deps?.npmLatest ?? npmLatest,
143
+ };
144
+ const cfg = () => ctx.controller.config;
145
+ return async function handleApi(req, res) {
146
+ const abort = new AbortController();
147
+ res.once('close', () => abort.abort());
148
+ try {
149
+ const { method, body } = await parseRequest(req);
150
+ const signal = abort.signal;
151
+ let payload;
152
+ switch (method) {
153
+ case 'ping':
154
+ payload = { plugin: ctx.pkg.name, version: ctx.pkg.version, node: process.version, boot: BOOT_ID };
155
+ break;
156
+ case 'self-check': {
157
+ try {
158
+ const latest = await d.npmLatest(ctx.pkg.name, cfg().timeoutMs ?? 20_000);
159
+ payload = { current: ctx.pkg.version, latest: latest.version, outdated: isNewerVersion(latest.version, ctx.pkg.version) };
160
+ }
161
+ catch (err) {
162
+ payload = { current: ctx.pkg.version, latest: null, outdated: false, error: err instanceof Error ? err.message : String(err) };
163
+ }
164
+ break;
165
+ }
166
+ case 'self-upgrade': {
167
+ const latest = await d.npmLatest(ctx.pkg.name, cfg().timeoutMs ?? 20_000);
168
+ const result = await ctx.onMutation(() => addDshPlugin(`${ctx.pkg.name}@${latest.version}`));
169
+ payload = { pkg: ctx.pkg.name, version: latest.version, usedAllowAllBuilds: result.usedAllowAllBuilds, needsRestart: true };
170
+ break;
171
+ }
172
+ case 'registry': {
173
+ await ctx.controller.ensureReady();
174
+ const snap = await ctx.controller.snapshot({ force: boolArg(body.force), signal });
175
+ payload = { plugins: snap.loaded.registry.plugins, registryState: snap.loaded };
176
+ break;
177
+ }
178
+ case 'market': {
179
+ await ctx.controller.ensureReady();
180
+ // GUI policy:忽略客户端 withLatest,固定 true;limit clamp 1..50
181
+ const limitRaw = Number(body.limit);
182
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(50, Math.max(1, Math.floor(limitRaw))) : 50;
183
+ const offsetRaw = Number(body.offset);
184
+ const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
185
+ const category = typeof body.category === 'string' && CATEGORIES.includes(body.category)
186
+ ? body.category
187
+ : null;
188
+ const result = await d.listMarket(cfg(), {
189
+ query: strArg(body, 'query'),
190
+ category,
191
+ offset,
192
+ limit,
193
+ force: boolArg(body.force),
194
+ withLatest: true,
195
+ namespace: 'host',
196
+ signal,
197
+ });
198
+ payload = { ...result };
199
+ break;
200
+ }
201
+ case 'installed': {
202
+ await ctx.controller.ensureReady();
203
+ const result = await d.listInstalledWithMeta(cfg(), { namespace: 'host', signal });
204
+ payload = { ...result };
205
+ break;
206
+ }
207
+ case 'readme': {
208
+ const target = strArg(body, 'pkg');
209
+ if (!target)
210
+ throw new ApiProtocolError(400, '缺少 pkg');
211
+ const result = await readInstalledPluginReadme(target);
212
+ payload = { ...result };
213
+ break;
214
+ }
215
+ case 'status':
216
+ payload = { ...publicInstallStatus() };
217
+ break;
218
+ case 'install': {
219
+ const id = strArg(body, 'id');
220
+ if (!id)
221
+ throw new ApiProtocolError(400, '缺少 id');
222
+ const version = typeof body.version === 'string' ? body.version : undefined;
223
+ const result = await ctx.onMutation(() => d.installFromRegistry(id, cfg(), { version, namespace: 'host' }));
224
+ payload = { ...result };
225
+ break;
226
+ }
227
+ case 'uninstall': {
228
+ const target = strArg(body, 'pkg');
229
+ if (!target)
230
+ throw new ApiProtocolError(400, '缺少 pkg');
231
+ const result = await ctx.onMutation(() => d.uninstallPlugin(target, cfg(), { namespace: 'host' }));
232
+ payload = { ...result };
233
+ break;
234
+ }
235
+ case 'upgrade': {
236
+ const target = strArg(body, 'pkg');
237
+ if (!target)
238
+ throw new ApiProtocolError(400, '缺少 pkg');
239
+ const result = await ctx.onMutation(() => d.upgradePlugin(target, cfg(), { namespace: 'host' }));
240
+ payload = { ...result };
241
+ break;
242
+ }
243
+ case 'restart': {
244
+ const result = scheduleRestart(servingPort(req));
245
+ payload = { ...result };
246
+ break;
247
+ }
248
+ case 'registry-config': {
249
+ const snap = await ctx.controller.snapshot();
250
+ payload = { ...snapshotPayload(snap) };
251
+ break;
252
+ }
253
+ case 'registry-config-apply': {
254
+ if (typeof body.registryUrl !== 'string')
255
+ throw new ApiProtocolError(400, '缺少 registryUrl');
256
+ const snap = await ctx.controller.apply(body.registryUrl, { signal });
257
+ payload = { applied: true, ...snapshotPayload(snap) };
258
+ break;
259
+ }
260
+ case 'registry-default-download': {
261
+ const loaded = await ctx.controller.loadDefault({ force: true, signal });
262
+ payload = { registry: loaded.registry, registryState: loaded };
263
+ break;
264
+ }
265
+ case 'registry-diagnose': {
266
+ await ctx.controller.ensureReady();
267
+ const snap = await ctx.controller.snapshot();
268
+ const check = await d.checkRegistryEntries(snap.loaded.registry, { signal });
269
+ payload = { registryState: snap.loaded, check };
270
+ break;
271
+ }
272
+ default:
273
+ throw new ApiProtocolError(404, `未知 method: ${method}`);
274
+ }
275
+ sendJson(res, 200, { ok: true, ...payload });
276
+ }
277
+ catch (err) {
278
+ const { status, payload } = errorStatus(err);
279
+ if (!res.headersSent)
280
+ sendJson(res, status, payload);
281
+ else
282
+ res.destroy();
283
+ }
284
+ };
285
+ }
package/lib/core/httpx.js CHANGED
@@ -1,29 +1,42 @@
1
1
  /**
2
- * 安全基线 §17.1:仅 HTTPS(loopback http 例外)+ 响应大小上限 + 超时。
3
- * skillhub 的缺口(无统一体积上限)在这里补齐。
2
+ * 安全基线 §17.1:仅 HTTPS(loopback http 例外)+ 响应大小上限 + 超时 + 手动重定向。
3
+ * 所有 HTTP JSON/text/HEAD 请求共享同一 primitive:每一跳 assertSafeUrl、最多 3 跳、
4
+ * 循环检测、signal 传播、返回最终 URL;registry/homepage/icon 诊断不得另起一套。
4
5
  */
6
+ import { Buffer } from 'node:buffer';
5
7
  export class HttpError extends Error {
6
8
  status;
7
- constructor(status, message) {
9
+ /** 2xx 响应的 headers(供上层识别限流等场景);body-cap/协议类错误无 headers */
10
+ headers;
11
+ constructor(status, message, headers) {
8
12
  super(message);
9
13
  this.status = status;
14
+ this.headers = headers;
10
15
  }
11
16
  }
12
17
  const MAX_DEFAULT = 2 * 1024 * 1024; // 2MB:registry/npm metadata 足够
18
+ const MAX_REDIRECTS = 3;
19
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
20
+ const USER_AGENT = 'dsh-m (personal marketplace)';
13
21
  export function assertSafeUrl(raw) {
14
22
  let url;
15
23
  try {
16
24
  url = new URL(raw);
17
25
  }
18
26
  catch {
19
- throw new HttpError(400, `无效 URL: ${raw}`);
27
+ throw new HttpError(400, `无效 URL`);
20
28
  }
21
29
  if (url.protocol === 'https:')
22
30
  return url;
23
- if (url.protocol === 'http:' && ['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) {
31
+ const hostname = url.hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
32
+ if (url.protocol === 'http:' && ['127.0.0.1', 'localhost', '::1'].includes(hostname)) {
24
33
  return url; // 本地 registry 覆盖调试用(DESIGN.md §2.1)
25
34
  }
26
- throw new HttpError(400, `仅允许 HTTPS: ${raw}`);
35
+ throw new HttpError(400, '仅允许 HTTPS(loopback 可用 HTTP)');
36
+ }
37
+ /** UTF-8 fatal 解码:非法序列直接抛错,不用替换字符静默吞掉。 */
38
+ export function decodeUtf8Fatal(buf) {
39
+ return new TextDecoder('utf-8', { fatal: true }).decode(buf);
27
40
  }
28
41
  async function readCapped(res, maxBytes) {
29
42
  const declared = Number(res.headers.get('content-length') || 0);
@@ -47,46 +60,90 @@ async function readCapped(res, maxBytes) {
47
60
  }
48
61
  return Buffer.concat(chunks);
49
62
  }
50
- export async function fetchJsonLimited(url, opts = {}) {
51
- assertSafeUrl(url);
52
- const res = await fetch(url, {
53
- signal: AbortSignal.timeout(opts.timeoutMs ?? 20_000),
54
- headers: { accept: 'application/json', 'user-agent': 'dsh-m (personal marketplace)', ...opts.headers },
55
- redirect: 'follow',
63
+ /**
64
+ * 统一安全 fetch:redirect:'manual' + 每跳 assertSafeUrl + 最多 3 跳 + 循环检测 +
65
+ * timeout/外部 signal 合并 + body cap。HEAD 不读 body。
66
+ */
67
+ export async function fetchLimited(url, opts = {}) {
68
+ const timeoutMs = opts.timeoutMs ?? 20_000;
69
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
70
+ const signal = opts.signal ? AbortSignal.any([timeoutSignal, opts.signal]) : timeoutSignal;
71
+ let current = assertSafeUrl(url);
72
+ const seen = new Set([current.toString()]);
73
+ for (let hop = 0;; hop++) {
74
+ const res = await fetch(current, {
75
+ redirect: 'manual',
76
+ signal,
77
+ method: opts.method ?? 'GET',
78
+ headers: { 'user-agent': USER_AGENT, ...opts.headers },
79
+ });
80
+ if (REDIRECT_STATUSES.has(res.status)) {
81
+ await res.body?.cancel().catch(() => undefined);
82
+ if (hop >= MAX_REDIRECTS)
83
+ throw new HttpError(502, `重定向超过 ${MAX_REDIRECTS} 跳`);
84
+ const location = res.headers.get('location');
85
+ if (!location)
86
+ throw new HttpError(502, '重定向缺少 Location');
87
+ let next;
88
+ try {
89
+ next = new URL(location, current);
90
+ }
91
+ catch {
92
+ throw new HttpError(502, '重定向 Location 无效');
93
+ }
94
+ assertSafeUrl(next.toString());
95
+ const key = next.toString();
96
+ if (seen.has(key))
97
+ throw new HttpError(502, '检测到重定向循环');
98
+ seen.add(key);
99
+ current = next;
100
+ continue;
101
+ }
102
+ const buffer = opts.method === 'HEAD' ? Buffer.alloc(0) : await readCapped(res, opts.maxBytes ?? MAX_DEFAULT);
103
+ return { status: res.status, ok: res.ok, finalUrl: current.toString(), headers: res.headers, buffer };
104
+ }
105
+ }
106
+ export async function fetchJsonLimitedMeta(url, opts = {}) {
107
+ const res = await fetchLimited(url, {
108
+ ...opts,
109
+ headers: { accept: 'application/json', ...opts.headers },
56
110
  });
57
111
  if (!res.ok)
58
- throw new HttpError(res.status, `HTTP ${res.status}: ${url}`);
59
- const buf = await readCapped(res, opts.maxBytes ?? MAX_DEFAULT);
112
+ throw new HttpError(res.status, `HTTP ${res.status}`, res.headers);
113
+ let text;
60
114
  try {
61
- return JSON.parse(buf.toString('utf8'));
115
+ text = decodeUtf8Fatal(res.buffer);
62
116
  }
63
- catch (err) {
64
- throw new HttpError(502, `响应不是合法 JSON: ${url}`);
117
+ catch {
118
+ throw new HttpError(502, '响应不是合法 UTF-8');
119
+ }
120
+ try {
121
+ return { data: JSON.parse(text), finalUrl: res.finalUrl };
122
+ }
123
+ catch {
124
+ throw new HttpError(502, '响应不是合法 JSON');
65
125
  }
66
126
  }
127
+ /** 旧契约兼容:只返回解析后的 JSON。 */
128
+ export async function fetchJsonLimited(url, opts = {}) {
129
+ return (await fetchJsonLimitedMeta(url, opts)).data;
130
+ }
67
131
  export async function fetchTextLimited(url, opts = {}) {
68
- assertSafeUrl(url);
69
- const res = await fetch(url, {
70
- signal: AbortSignal.timeout(opts.timeoutMs ?? 20_000),
71
- headers: { 'user-agent': 'dsh-m (personal marketplace)', ...opts.headers },
72
- redirect: 'follow',
73
- });
132
+ const res = await fetchLimited(url, opts);
74
133
  if (!res.ok)
75
- throw new HttpError(res.status, `HTTP ${res.status}: ${url}`);
76
- const buf = await readCapped(res, opts.maxBytes ?? MAX_DEFAULT);
77
- return buf.toString('utf8');
134
+ throw new HttpError(res.status, `HTTP ${res.status}`, res.headers);
135
+ try {
136
+ return decodeUtf8Fatal(res.buffer);
137
+ }
138
+ catch {
139
+ throw new HttpError(502, '响应不是合法 UTF-8');
140
+ }
78
141
  }
79
- /** 可达性探测(icon/homepage 校验用):2xx 即可达。 */
80
- export async function isReachable(url, timeoutMs = 8000) {
142
+ /** 可达性探测(icon/homepage 诊断用):2xx 即可达;HEAD 405 视为可达。 */
143
+ export async function isReachable(url, timeoutMs = 8000, signal) {
81
144
  try {
82
- assertSafeUrl(url);
83
- const res = await fetch(url, {
84
- method: 'HEAD',
85
- signal: AbortSignal.timeout(timeoutMs),
86
- headers: { 'user-agent': 'dsh-m (personal marketplace)' },
87
- redirect: 'follow',
88
- });
89
- return res.ok || res.status === 405; // 有的站点拒绝 HEAD,视为可达
145
+ const res = await fetchLimited(url, { method: 'HEAD', timeoutMs, signal });
146
+ return res.ok || res.status === 405;
90
147
  }
91
148
  catch {
92
149
  return false;