dsh-m 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +25 -10
- package/README.md +25 -10
- package/docs/DESIGN.md +172 -0
- package/lib/cli.js +134 -63
- package/lib/client.js +778 -114
- package/lib/core/host-api.js +285 -0
- package/lib/core/httpx.js +93 -36
- package/lib/core/market.js +477 -105
- package/lib/core/npm-integrity.js +141 -0
- package/lib/core/registry-check.js +111 -0
- package/lib/core/registry-controller.js +321 -0
- package/lib/core/registry.js +634 -100
- package/lib/core/versions.js +66 -23
- package/lib/host.js +22 -173
- package/lib/tools.js +54 -40
- package/package.json +3 -2
- package/registry.json +29 -7
- package/DESIGN.md +0 -140
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
|
27
|
+
throw new HttpError(400, `无效 URL`);
|
|
20
28
|
}
|
|
21
29
|
if (url.protocol === 'https:')
|
|
22
30
|
return url;
|
|
23
|
-
|
|
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,
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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}
|
|
59
|
-
|
|
112
|
+
throw new HttpError(res.status, `HTTP ${res.status}`, res.headers);
|
|
113
|
+
let text;
|
|
60
114
|
try {
|
|
61
|
-
|
|
115
|
+
text = decodeUtf8Fatal(res.buffer);
|
|
62
116
|
}
|
|
63
|
-
catch
|
|
64
|
-
throw new HttpError(502,
|
|
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
|
-
|
|
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}
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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;
|