@shendeguize/remote-dsh-center 0.4.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +197 -0
  3. package/README.md +174 -0
  4. package/package.json +48 -0
  5. package/scripts/install.mjs +208 -0
  6. package/src/api.js +725 -0
  7. package/src/cli.js +1445 -0
  8. package/src/config-sync.js +157 -0
  9. package/src/daemon.js +362 -0
  10. package/src/defaults.js +89 -0
  11. package/src/dsh-workspace.js +467 -0
  12. package/src/launcher.js +627 -0
  13. package/src/lib/bundle.js +82 -0
  14. package/src/lib/bus.js +109 -0
  15. package/src/lib/capture.js +53 -0
  16. package/src/lib/clock.js +18 -0
  17. package/src/lib/entry.js +27 -0
  18. package/src/lib/errors.js +88 -0
  19. package/src/lib/logfile.js +65 -0
  20. package/src/lib/machine.js +63 -0
  21. package/src/lib/origin-guard.js +64 -0
  22. package/src/lib/pool.js +88 -0
  23. package/src/lib/proto.js +457 -0
  24. package/src/lib/semver.js +103 -0
  25. package/src/lib/shq.js +112 -0
  26. package/src/lib/ssh.js +647 -0
  27. package/src/lib/validate.js +363 -0
  28. package/src/monitor.js +145 -0
  29. package/src/patchsync.js +310 -0
  30. package/src/ports.js +93 -0
  31. package/src/prober.js +185 -0
  32. package/src/server.js +449 -0
  33. package/src/settings-file.js +550 -0
  34. package/src/ssh-config.js +152 -0
  35. package/src/store.js +772 -0
  36. package/src/tunnel.js +589 -0
  37. package/src/updater.js +450 -0
  38. package/src/web/actions.js +409 -0
  39. package/src/web/api.js +262 -0
  40. package/src/web/app.js +347 -0
  41. package/src/web/components/config-sync-dialog.js +469 -0
  42. package/src/web/components/confirm-dialog.js +61 -0
  43. package/src/web/components/defaults-card.js +216 -0
  44. package/src/web/components/event-panel.js +98 -0
  45. package/src/web/components/host-drawer.js +1039 -0
  46. package/src/web/components/host-table.js +317 -0
  47. package/src/web/components/hub.js +143 -0
  48. package/src/web/components/iframe-pane.js +377 -0
  49. package/src/web/components/manager-card.js +65 -0
  50. package/src/web/components/setup-wizard.js +726 -0
  51. package/src/web/components/tabbar.js +577 -0
  52. package/src/web/components/toast-region.js +107 -0
  53. package/src/web/favicon.svg +7 -0
  54. package/src/web/form.js +220 -0
  55. package/src/web/host-presentation.js +73 -0
  56. package/src/web/host-rules.js +76 -0
  57. package/src/web/index.html +17 -0
  58. package/src/web/router.js +118 -0
  59. package/src/web/setup-schema.js +203 -0
  60. package/src/web/sse.js +118 -0
  61. package/src/web/store.js +405 -0
  62. package/src/web/style.css +813 -0
  63. package/src/web/utils.js +210 -0
@@ -0,0 +1,467 @@
1
+ /**
2
+ * 把当前 dsh web 的实测 CWD 登记为它自己的 Workspace。
3
+ *
4
+ * 路径优先取队首重新读取的 HostView.web.cwd;旧状态缺诊断 CWD 时,才通过同一条
5
+ * 已连通的 127.0.0.1 映射读取官方 host.describe,再调用 workspace.create。
6
+ * 调用方没有任何路径入参。
7
+ */
8
+
9
+ import { randomUUID } from 'node:crypto';
10
+
11
+ import { DshError } from './lib/errors.js';
12
+ import { hostQueue } from './lib/ssh.js';
13
+
14
+ export const DSH_WORKSPACE_RESPONSE_MAX_BYTES = 64 * 1024;
15
+ export const DSH_WORKSPACE_TIMEOUT_MS = 15_000;
16
+
17
+ const SAFE_MAPPED_URL_RE = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})\/$/u;
18
+ const activeHosts = new Set();
19
+
20
+ function workspaceError(code, message, host) {
21
+ return new DshError(code, message, { host });
22
+ }
23
+
24
+ function protocolError(host) {
25
+ return workspaceError(
26
+ 'WORKSPACE_REGISTER_FAILED',
27
+ 'dsh web 返回的 Workspace 响应无法安全确认,请稍后重试',
28
+ host,
29
+ );
30
+ }
31
+
32
+ function upstreamError(host) {
33
+ return workspaceError(
34
+ 'WORKSPACE_REGISTER_FAILED',
35
+ 'dsh Workspace 登记失败,请稍后重试',
36
+ host,
37
+ );
38
+ }
39
+
40
+ function timeoutError(host) {
41
+ return workspaceError(
42
+ 'WORKSPACE_REGISTER_TIMEOUT',
43
+ 'dsh Workspace 登记超时;创建操作是幂等的,可安全重试',
44
+ host,
45
+ );
46
+ }
47
+
48
+ function cwdUnavailableError(host) {
49
+ return workspaceError(
50
+ 'WORKSPACE_CWD_UNAVAILABLE',
51
+ '当前 dsh web 的实际工作目录不可用,请重启后再试',
52
+ host,
53
+ );
54
+ }
55
+
56
+ function acquireSlot(host) {
57
+ if (activeHosts.has(host)) {
58
+ throw workspaceError(
59
+ 'WORKSPACE_BUSY',
60
+ '该主机已有 dsh Workspace 登记正在进行,请稍后重试',
61
+ host,
62
+ );
63
+ }
64
+ activeHosts.add(host);
65
+ return () => activeHosts.delete(host);
66
+ }
67
+
68
+ function isRecord(value) {
69
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
70
+ }
71
+
72
+ function hasRequiredKeys(value, keys) {
73
+ if (!isRecord(value)) return false;
74
+ return keys.every((key) => Object.hasOwn(value, key));
75
+ }
76
+
77
+ function isAbsolutePath(value) {
78
+ return typeof value === 'string' && value.startsWith('/') && !value.includes('\0');
79
+ }
80
+
81
+ function safeMappedUrl(value) {
82
+ if (typeof value !== 'string') return null;
83
+ const match = SAFE_MAPPED_URL_RE.exec(value);
84
+ if (!match) return null;
85
+ const port = Number(match[1]);
86
+ return port >= 1 && port <= 65535 ? value : null;
87
+ }
88
+
89
+ function resolveCurrentView(host, resolveView) {
90
+ let current;
91
+ try {
92
+ current = resolveView(host);
93
+ } catch (error) {
94
+ if (error instanceof DshError) throw error;
95
+ throw workspaceError('INTERNAL', '读取主机当前状态失败,请稍后重试', host);
96
+ }
97
+ if (current !== null && (typeof current === 'object' || typeof current === 'function')) {
98
+ let then;
99
+ try {
100
+ then = current.then;
101
+ } catch {
102
+ throw workspaceError('INTERNAL', '读取主机当前状态失败,请稍后重试', host);
103
+ }
104
+ if (typeof then === 'function') {
105
+ Promise.resolve(current).catch(() => {});
106
+ throw workspaceError('INTERNAL', '主机状态读取器必须同步返回当前视图', host);
107
+ }
108
+ }
109
+ return current;
110
+ }
111
+
112
+ function requireRunnableView(host, resolveView) {
113
+ const current = resolveCurrentView(host, resolveView);
114
+ if (!current) throw workspaceError('NOT_FOUND', `未知主机 ${host}`, host);
115
+ if (!['running', 'degraded'].includes(current.phase)) {
116
+ throw workspaceError(
117
+ 'PHASE_CONFLICT',
118
+ '登记 dsh Workspace 要求主机处于 running/degraded',
119
+ host,
120
+ );
121
+ }
122
+
123
+ const configuredWorkdir = current.config?.workdir ?? null;
124
+ if (configuredWorkdir === null) {
125
+ throw workspaceError(
126
+ 'WORKSPACE_WORKDIR_REQUIRED',
127
+ '请先为主机配置启动目录并重启 dsh web,再登记 Workspace',
128
+ host,
129
+ );
130
+ }
131
+ if (current.web?.workdir !== configuredWorkdir) {
132
+ throw workspaceError(
133
+ 'PHASE_CONFLICT',
134
+ '启动目录尚未应用到当前实例,请重启此主机的 dsh web 后再登记',
135
+ host,
136
+ );
137
+ }
138
+ if (current.tunnel?.connected !== true) {
139
+ throw workspaceError(
140
+ 'PHASE_CONFLICT',
141
+ '当前 dsh web 映射尚未连通,请等待连接恢复后再登记 Workspace',
142
+ host,
143
+ );
144
+ }
145
+
146
+ const cwd = current.web?.cwd;
147
+ const cwdMissing = cwd === null || cwd === undefined || cwd === '';
148
+ if (!cwdMissing && !isAbsolutePath(cwd)) {
149
+ throw workspaceError(
150
+ 'WORKSPACE_INVALID_PATH',
151
+ '当前 dsh web 返回的工作目录不是绝对路径,无法登记',
152
+ host,
153
+ );
154
+ }
155
+
156
+ const mappedUrl = safeMappedUrl(current.mappedUrl);
157
+ if (mappedUrl === null) throw protocolError(host);
158
+ return { cwd: cwdMissing ? null : cwd, mappedUrl };
159
+ }
160
+
161
+ function linkSignals(signals) {
162
+ const controller = new AbortController();
163
+ const listeners = [];
164
+ const abortFrom = (source) => {
165
+ if (!controller.signal.aborted) controller.abort(source.reason);
166
+ };
167
+ for (const source of signals.filter(Boolean)) {
168
+ if (source.aborted) {
169
+ abortFrom(source);
170
+ break;
171
+ }
172
+ const listener = () => abortFrom(source);
173
+ source.addEventListener('abort', listener, { once: true });
174
+ listeners.push([source, listener]);
175
+ }
176
+ return {
177
+ signal: controller.signal,
178
+ dispose() {
179
+ for (const [source, listener] of listeners) source.removeEventListener('abort', listener);
180
+ },
181
+ };
182
+ }
183
+
184
+ async function abortable(signal, start) {
185
+ if (signal.aborted) throw signal.reason;
186
+ let onAbort;
187
+ const aborted = new Promise((resolve, reject) => {
188
+ onAbort = () => reject(signal.reason);
189
+ signal.addEventListener('abort', onAbort, { once: true });
190
+ });
191
+ try {
192
+ return await Promise.race([
193
+ Promise.resolve().then(start),
194
+ aborted,
195
+ ]);
196
+ } finally {
197
+ signal.removeEventListener('abort', onAbort);
198
+ }
199
+ }
200
+
201
+ function cancelBody(body) {
202
+ try {
203
+ const cancelled = body?.cancel?.();
204
+ if (cancelled && typeof cancelled.catch === 'function') cancelled.catch(() => {});
205
+ } catch {
206
+ // 丢弃上游正文是尽力清理;清理失败不能覆盖原本的安全错误。
207
+ }
208
+ }
209
+
210
+ async function readCappedJson(response, signal, host) {
211
+ const length = response.headers?.get?.('content-length');
212
+ if (/^[0-9]+$/u.test(length ?? '') && Number(length) > DSH_WORKSPACE_RESPONSE_MAX_BYTES) {
213
+ cancelBody(response.body);
214
+ throw protocolError(host);
215
+ }
216
+
217
+ const reader = response.body?.getReader?.();
218
+ if (!reader) throw protocolError(host);
219
+ const chunks = [];
220
+ let total = 0;
221
+ try {
222
+ for (;;) {
223
+ const part = await abortable(signal, () => reader.read());
224
+ if (!isRecord(part) || typeof part.done !== 'boolean') throw protocolError(host);
225
+ if (part.done) break;
226
+ if (!(part.value instanceof Uint8Array)) throw protocolError(host);
227
+ total += part.value.byteLength;
228
+ if (total > DSH_WORKSPACE_RESPONSE_MAX_BYTES) {
229
+ cancelBody(reader);
230
+ throw protocolError(host);
231
+ }
232
+ chunks.push(part.value);
233
+ }
234
+ } catch (error) {
235
+ if (signal.aborted) cancelBody(reader);
236
+ throw error;
237
+ } finally {
238
+ try {
239
+ reader.releaseLock();
240
+ } catch {
241
+ // 已取消或已释放。
242
+ }
243
+ }
244
+
245
+ const bytes = new Uint8Array(total);
246
+ let offset = 0;
247
+ for (const chunk of chunks) {
248
+ bytes.set(chunk, offset);
249
+ offset += chunk.byteLength;
250
+ }
251
+ let text;
252
+ try {
253
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
254
+ } catch {
255
+ throw protocolError(host);
256
+ }
257
+ try {
258
+ return JSON.parse(text);
259
+ } catch {
260
+ throw protocolError(host);
261
+ }
262
+ }
263
+
264
+ function validWorkspace(value) {
265
+ return hasRequiredKeys(value, [
266
+ 'workspaceId',
267
+ 'path',
268
+ 'title',
269
+ ])
270
+ && typeof value.workspaceId === 'string'
271
+ && value.workspaceId.length > 0
272
+ && isAbsolutePath(value.path)
273
+ && typeof value.title === 'string';
274
+ }
275
+
276
+ function parseRpcResult(body, expectedRpcId, host, onError = () => {
277
+ throw upstreamError(host);
278
+ }) {
279
+ if (!hasRequiredKeys(body, ['type', 'rpcId', 'result'])) throw protocolError(host);
280
+ if (body.type !== 'server-response' || body.rpcId !== expectedRpcId) throw protocolError(host);
281
+
282
+ const { result } = body;
283
+ if (!isRecord(result) || typeof result.ok !== 'boolean') throw protocolError(host);
284
+ if (result.ok === false) {
285
+ if (
286
+ !hasRequiredKeys(result, ['ok', 'error'])
287
+ || !hasRequiredKeys(result.error, ['code'])
288
+ || typeof result.error.code !== 'string'
289
+ ) {
290
+ throw protocolError(host);
291
+ }
292
+ return onError(result.error.code);
293
+ }
294
+ if (!hasRequiredKeys(result, ['ok', 'value'])) throw protocolError(host);
295
+ return result.value;
296
+ }
297
+
298
+ function parseWorkspaceResponse(body, expectedRpcId, host) {
299
+ const value = parseRpcResult(body, expectedRpcId, host, (code) => {
300
+ if (code === 'workspace-invalid-path') {
301
+ throw workspaceError(
302
+ 'WORKSPACE_INVALID_PATH',
303
+ 'dsh web 无法登记当前实际工作目录,请确认目录仍存在且可访问',
304
+ host,
305
+ );
306
+ }
307
+ throw upstreamError(host);
308
+ });
309
+ if (
310
+ !hasRequiredKeys(value, ['workspace', 'created'])
311
+ || typeof value.created !== 'boolean'
312
+ || !validWorkspace(value.workspace)
313
+ ) {
314
+ throw protocolError(host);
315
+ }
316
+
317
+ const { workspace, created } = value;
318
+ return {
319
+ created,
320
+ workspaceId: workspace.workspaceId,
321
+ title: workspace.title,
322
+ path: workspace.path,
323
+ };
324
+ }
325
+
326
+ function parseHostDescribeResponse(body, expectedRpcId, host) {
327
+ const value = parseRpcResult(body, expectedRpcId, host);
328
+ if (!isRecord(value)) throw protocolError(host);
329
+ if (!hasRequiredKeys(value, ['cwd']) || !isAbsolutePath(value.cwd)) {
330
+ throw cwdUnavailableError(host);
331
+ }
332
+ return value.cwd;
333
+ }
334
+
335
+ async function callDshRpc(host, {
336
+ mappedUrl,
337
+ method,
338
+ payload,
339
+ fetchImpl,
340
+ signal,
341
+ parseResponse,
342
+ }) {
343
+ const rpcId = randomUUID();
344
+ const body = JSON.stringify({
345
+ type: 'client-request',
346
+ rpcId,
347
+ method,
348
+ payload,
349
+ });
350
+
351
+ let response;
352
+ try {
353
+ response = await abortable(signal, () => fetchImpl(
354
+ `${mappedUrl}api/${method}`,
355
+ {
356
+ method: 'POST',
357
+ headers: { 'content-type': 'application/json' },
358
+ body,
359
+ signal,
360
+ },
361
+ ));
362
+ } catch (error) {
363
+ if (signal.aborted || error?.name === 'AbortError' || error?.name === 'TimeoutError') {
364
+ throw timeoutError(host);
365
+ }
366
+ if (error instanceof DshError) throw error;
367
+ throw upstreamError(host);
368
+ }
369
+
370
+ if (!isRecord(response) || response.ok !== true) {
371
+ cancelBody(response?.body);
372
+ throw upstreamError(host);
373
+ }
374
+
375
+ let parsed;
376
+ try {
377
+ parsed = await readCappedJson(response, signal, host);
378
+ } catch (error) {
379
+ if (signal.aborted || error?.name === 'AbortError' || error?.name === 'TimeoutError') {
380
+ throw timeoutError(host);
381
+ }
382
+ if (error instanceof DshError) throw error;
383
+ throw protocolError(host);
384
+ }
385
+ return parseResponse(parsed, rpcId, host);
386
+ }
387
+
388
+ function callHostDescribeRpc(host, options) {
389
+ return callDshRpc(host, {
390
+ ...options,
391
+ method: 'host.describe',
392
+ payload: {},
393
+ parseResponse: parseHostDescribeResponse,
394
+ });
395
+ }
396
+
397
+ function callWorkspaceRpc(host, { cwd, ...options }) {
398
+ return callDshRpc(host, {
399
+ ...options,
400
+ method: 'workspace.create',
401
+ payload: { path: cwd },
402
+ parseResponse: parseWorkspaceResponse,
403
+ });
404
+ }
405
+
406
+ /**
407
+ * @param {string} host
408
+ * @param {{
409
+ * resolveView:(host:string)=>any,
410
+ * fetchImpl?:(url:string, init:object)=>Promise<any>,
411
+ * signal?:AbortSignal,
412
+ * timeoutMs?:number,
413
+ * }} deps
414
+ */
415
+ export async function registerDshWorkspace(host, {
416
+ resolveView,
417
+ fetchImpl = globalThis.fetch,
418
+ signal,
419
+ timeoutMs = DSH_WORKSPACE_TIMEOUT_MS,
420
+ } = {}) {
421
+ if (typeof host !== 'string' || host.length === 0) {
422
+ throw new DshError('VALIDATION', '主机名不能为空');
423
+ }
424
+ if (typeof resolveView !== 'function' || typeof fetchImpl !== 'function') {
425
+ throw new DshError('VALIDATION', 'Workspace 登记缺少主机状态或 HTTP 执行器');
426
+ }
427
+ const boundedTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0
428
+ ? Math.min(Math.trunc(timeoutMs), DSH_WORKSPACE_TIMEOUT_MS)
429
+ : DSH_WORKSPACE_TIMEOUT_MS;
430
+ const release = acquireSlot(host);
431
+ const deadline = new AbortController();
432
+ const timer = setTimeout(() => deadline.abort(timeoutError(host)), boundedTimeout);
433
+ const operation = linkSignals([deadline.signal, signal]);
434
+
435
+ try {
436
+ const queued = hostQueue(host).run('workspace-register', async (queueSignal) => {
437
+ const running = linkSignals([operation.signal, queueSignal]);
438
+ try {
439
+ if (running.signal.aborted) throw timeoutError(host);
440
+ const current = requireRunnableView(host, resolveView);
441
+ const cwd = current.cwd ?? await callHostDescribeRpc(host, {
442
+ mappedUrl: current.mappedUrl,
443
+ fetchImpl,
444
+ signal: running.signal,
445
+ });
446
+ return await callWorkspaceRpc(host, {
447
+ ...current,
448
+ cwd,
449
+ fetchImpl,
450
+ signal: running.signal,
451
+ });
452
+ } finally {
453
+ running.dispose();
454
+ }
455
+ }, { timeoutMs: boundedTimeout });
456
+ try {
457
+ return await abortable(operation.signal, () => queued);
458
+ } catch (error) {
459
+ if (operation.signal.aborted || error?.code === 'SSH_TIMEOUT') throw timeoutError(host);
460
+ throw error;
461
+ }
462
+ } finally {
463
+ clearTimeout(timer);
464
+ operation.dispose();
465
+ release();
466
+ }
467
+ }