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.
- package/README.en.md +104 -0
- package/README.md +61 -23
- package/docs/DESIGN.md +172 -0
- package/lib/cli.js +134 -63
- package/lib/client.js +1254 -217
- package/lib/core/dsh-cli.js +118 -10
- package/lib/core/host-api.js +285 -0
- package/lib/core/httpx.js +93 -36
- package/lib/core/installed.js +69 -4
- package/lib/core/market.js +481 -104
- package/lib/core/npm-integrity.js +141 -0
- package/lib/core/progress.js +113 -0
- package/lib/core/registry-check.js +111 -0
- package/lib/core/registry-controller.js +321 -0
- package/lib/core/registry.js +634 -98
- package/lib/core/versions.js +80 -9
- package/lib/host.js +22 -161
- package/lib/tools.js +56 -41
- package/package.json +4 -2
- package/registry.json +95 -7
- package/DESIGN.md +0 -140
package/lib/core/dsh-cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
*
|
|
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;
|