@vibe-x/dsh-html-workbench 0.3.0 → 0.3.2
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.md +68 -5
- package/assets/workbench.html +51 -14
- package/dsh.plugin.json +1 -1
- package/lib/client.js +527 -175
- package/package.json +2 -1
- package/scripts/workbench.py +486 -68
- package/src/client.js +513 -175
- package/src/host.js +515 -114
- package/src/index.js +96 -11
package/src/host.js
CHANGED
|
@@ -28,33 +28,102 @@ return {
|
|
|
28
28
|
const PORT = opts.port || 4317
|
|
29
29
|
const SCRIPT = opts.script || null
|
|
30
30
|
const EDITOR_ROOT = opts.editorRoot || null
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
31
|
+
const PLATFORM = opts.platform || (typeof process !== 'undefined' ? process.platform : null)
|
|
32
|
+
const IS_WINDOWS = PLATFORM === 'win32'
|
|
33
|
+
// Reported verbatim in the diagnostics. A dynamic (cordis_define) load has no
|
|
34
|
+
// config, so `version` stays null — which is itself the answer to "is this the
|
|
35
|
+
// packaged build or a hot-loaded one?", the first question every report raises.
|
|
36
|
+
const VERSION = opts.version || null
|
|
37
|
+
// Candidates must be a SINGLE executable token: every call site appends its
|
|
38
|
+
// own arguments, so a multi-token entry like `py -3` would place the script
|
|
39
|
+
// path after the launcher's own flags and break argument forwarding. The `py`
|
|
40
|
+
// launcher is still supported — see `expandWindowsLauncher`, which asks it for
|
|
41
|
+
// the interpreter's absolute path and uses that instead.
|
|
42
|
+
const PYTHON_CANDIDATES = opts.pythonCommand
|
|
43
|
+
? [opts.pythonCommand]
|
|
44
|
+
: ['python3', 'python']
|
|
45
|
+
// Static packages inject the runtime directory resolved (and probed) by
|
|
46
|
+
// src/index.js. Dynamic loads use TEMP/TMPDIR or the current workspace.
|
|
47
|
+
// The folder name matches
|
|
48
|
+
// RUNTIME_DIR_NAME in workbench.py so both halves agree on one location.
|
|
35
49
|
const tempRoot = typeof process !== 'undefined' && process.env
|
|
36
50
|
? (process.env.TEMP || process.env.TMP || process.env.TMPDIR)
|
|
37
51
|
: null
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
// Strip a trailing separator from EITHER source: `%TEMP%` and an injected
|
|
53
|
+
// runtimeDir both legitimately end in one, and appending '/logs' to it would
|
|
54
|
+
// yield `C:\Temp\hwb\/logs` — a path Python tolerates but that reads as
|
|
55
|
+
// corruption in every log line and error message a user pastes back.
|
|
56
|
+
const trimSeparator = (value) => String(value).replace(/[\\/]+$/, '')
|
|
57
|
+
const currentDir = typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : '.'
|
|
58
|
+
const RUNTIME_DIR = opts.runtimeDir
|
|
59
|
+
? trimSeparator(opts.runtimeDir)
|
|
60
|
+
: trimSeparator(tempRoot || currentDir) + '/.html-workbench'
|
|
61
|
+
|
|
62
|
+
// These are REQUESTS, not guarantees. The service runs as a DSH sandboxed
|
|
63
|
+
// child that may write only the session workspace and a private temp folder
|
|
64
|
+
// the sandbox hands it — the ambient %TEMP% root seen by this parent is
|
|
65
|
+
// denied, so a folder the parent can create is not necessarily one the child
|
|
66
|
+
// can use. workbench.py therefore re-probes in-process, falls back on its
|
|
67
|
+
// own, and advertises the directories it settled on; `adoptResolvedPaths`
|
|
68
|
+
// below surfaces those, so the panel describes where the service really
|
|
69
|
+
// writes instead of echoing the path we merely asked for. Both are disposable
|
|
70
|
+
// cache/log data either way.
|
|
46
71
|
const LOG_DIR = RUNTIME_DIR + '/logs'
|
|
47
72
|
const VENDOR_CACHE = RUNTIME_DIR + '/vendor'
|
|
73
|
+
const hostLog = opts.hostLog || null
|
|
74
|
+
const nodeVersion = typeof process !== 'undefined' ? process.version : null
|
|
75
|
+
const arch = typeof process !== 'undefined' ? process.arch : null
|
|
76
|
+
const mode = typeof harness !== 'undefined' ? 'dynamic' : 'static'
|
|
77
|
+
let hostLogError = null
|
|
78
|
+
|
|
79
|
+
// Directories the running service reported; null until it says so.
|
|
80
|
+
let resolvedPaths = null
|
|
81
|
+
let serviceInfo = null
|
|
82
|
+
let startupLogFile
|
|
48
83
|
|
|
49
84
|
let assets = []
|
|
50
85
|
let seq = 0
|
|
51
86
|
let ownedProcess = null
|
|
52
87
|
let serviceRunning = false
|
|
53
88
|
let startError = null
|
|
89
|
+
let pythonCommand = null
|
|
90
|
+
let dependencyIssue = null
|
|
91
|
+
// A failed probe must be remembered: `resolve` runs on every keystroke and
|
|
92
|
+
// `health` on a timer, so re-probing would spawn three 5s subprocesses per
|
|
93
|
+
// call and flood the journal with one repeated line. The TTL still lets a
|
|
94
|
+
// user who just installed Python recover without reloading the plugin.
|
|
95
|
+
const PROBE_RETRY_MS = 30000
|
|
96
|
+
let pythonProbeFailedAt = 0
|
|
97
|
+
let pythonProbe = null
|
|
54
98
|
let disposed = false
|
|
99
|
+
let starting = null
|
|
100
|
+
let restarting = null
|
|
101
|
+
let serviceEpoch = 0
|
|
55
102
|
|
|
56
103
|
const shell = ctx.get('shell')
|
|
57
104
|
|
|
105
|
+
// ── Shell API: two shapes, one contract ──────────────────────────────────
|
|
106
|
+
//
|
|
107
|
+
// The ShellExecutor that public kernels ship (`@deepseek-ai/dsh-shell`
|
|
108
|
+
// 0.1.7-rc.2 / 0.2.0-rc.2) exposes `resolve()` + `execute()` only — `run()`
|
|
109
|
+
// and `start()` were never published, so on a stock installation every call
|
|
110
|
+
// below threw (`shell.start is not a function`) and the service could never
|
|
111
|
+
// start. Both shapes are honoured: an executor offering the legacy pair is
|
|
112
|
+
// driven exactly as before, anything else goes through `execute()` —
|
|
113
|
+
// `await execute(spec).result()` IS the old `run()` (the foreground
|
|
114
|
+
// projection whose ShellRunResult/CollectedOutput fields the diagnostics
|
|
115
|
+
// below already read), and the value `execute()` resolves to IS the old
|
|
116
|
+
// `start()` handle (ShellExecution extends ShellProcess: status / exitCode /
|
|
117
|
+
// signal / readOutput() / kill()).
|
|
118
|
+
const runShell = async (spec) =>
|
|
119
|
+
typeof shell.run === 'function' ? shell.run(spec) : (await shell.execute(spec)).result()
|
|
120
|
+
const startShell = async (spec) =>
|
|
121
|
+
// `onExpiry` only in this branch: `resolve()` defaults to `'kill'` with a
|
|
122
|
+
// 60s deadline, which would kill the long-running serve process a minute
|
|
123
|
+
// after start. The legacy branch inherits whatever its executor already
|
|
124
|
+
// did with a background handle.
|
|
125
|
+
typeof shell.start === 'function' ? shell.start(spec) : shell.execute({ ...spec, onExpiry: 'none' })
|
|
126
|
+
|
|
58
127
|
// ── Diagnostics journal ──────────────────────────────────────────────────
|
|
59
128
|
//
|
|
60
129
|
// A failed start used to surface as nothing but a red dot: the only signal
|
|
@@ -63,17 +132,75 @@ return {
|
|
|
63
132
|
// subprocess outcome now lands in this ring buffer, which `status` returns
|
|
64
133
|
// and the panel renders, so the failure is readable where it is observed.
|
|
65
134
|
const JOURNAL_LIMIT = 40
|
|
135
|
+
// Errors are the whole point of the journal, yet they are also the rarest
|
|
136
|
+
// entries: a service that fails at start then gets polled by `status` every
|
|
137
|
+
// few seconds would push the original traceback out of a plain ring buffer
|
|
138
|
+
// long before anyone opens the panel. So evict by level — drop the oldest
|
|
139
|
+
// non-error first, and only sacrifice an error when nothing else is left.
|
|
140
|
+
const JOURNAL_ERROR_FLOOR = 12
|
|
66
141
|
let journal = []
|
|
67
142
|
|
|
143
|
+
const trimJournal = () => {
|
|
144
|
+
if (journal.length <= JOURNAL_LIMIT) return
|
|
145
|
+
const overflow = journal.length - JOURNAL_LIMIT
|
|
146
|
+
let dropped = 0
|
|
147
|
+
const errors = journal.filter((entry) => entry.level === 'error').length
|
|
148
|
+
const keep = []
|
|
149
|
+
for (let i = 0; i < journal.length; i += 1) {
|
|
150
|
+
const entry = journal[i]
|
|
151
|
+
const isError = entry.level === 'error'
|
|
152
|
+
// Never let noise starve the error history, but do not grow without
|
|
153
|
+
// bound either: past the floor, errors age out like anything else.
|
|
154
|
+
const expendable = !isError || errors - dropped > JOURNAL_ERROR_FLOOR
|
|
155
|
+
if (dropped < overflow && expendable) {
|
|
156
|
+
dropped += 1
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
keep.push(entry)
|
|
160
|
+
}
|
|
161
|
+
// Errors alone can exceed the limit; fall back to a plain tail so the
|
|
162
|
+
// buffer still has a hard ceiling.
|
|
163
|
+
journal = keep.length > JOURNAL_LIMIT ? keep.slice(-JOURNAL_LIMIT) : keep
|
|
164
|
+
}
|
|
165
|
+
|
|
68
166
|
const note = (level, message, detail) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
167
|
+
const text = String(message || '').slice(0, 2000)
|
|
168
|
+
const raw = detail ? String(detail) : null
|
|
169
|
+
const body = raw && raw.length > 4000 ? raw.slice(0, 1000) + '\n…(中间输出已截断)\n' + raw.slice(-3000) : raw
|
|
170
|
+
const at = Date.now()
|
|
171
|
+
// Retrying a broken start replays the SAME failure verbatim, interleaved
|
|
172
|
+
// with the "restarting" line — so consecutive-only folding would miss it.
|
|
173
|
+
// Match anywhere, then move the entry to the end so its position still
|
|
174
|
+
// agrees with its timestamp when the panel renders newest-first.
|
|
175
|
+
let index = -1
|
|
176
|
+
for (let i = journal.length - 1; i >= 0; i -= 1) {
|
|
177
|
+
const entry = journal[i]
|
|
178
|
+
if (entry.level === level && entry.message === text && entry.detail === body) { index = i; break }
|
|
179
|
+
}
|
|
180
|
+
if (index >= 0) {
|
|
181
|
+
const entry = journal.splice(index, 1)[0]
|
|
182
|
+
entry.at = at
|
|
183
|
+
entry.repeated = (entry.repeated || 1) + 1
|
|
184
|
+
journal.push(entry)
|
|
185
|
+
} else {
|
|
186
|
+
journal.push({ at, firstAt: at, level, message: text, detail: body })
|
|
187
|
+
trimJournal()
|
|
188
|
+
}
|
|
189
|
+
const entry = journal[journal.length - 1]
|
|
190
|
+
if (entry.loggedAt != null && at - entry.loggedAt < 60000) return
|
|
191
|
+
entry.loggedAt = at
|
|
192
|
+
if (hostLog) {
|
|
193
|
+
try {
|
|
194
|
+
hostLog.append({ at, level, message: text, detail: body, port: PORT, version: VERSION,
|
|
195
|
+
firstAt: entry.firstAt, repeated: entry.repeated || 1 })
|
|
196
|
+
hostLogError = null
|
|
197
|
+
} catch (e) {
|
|
198
|
+
const failure = (e && e.message) || String(e)
|
|
199
|
+
if (hostLogError !== failure) console.error('[html-workbench] 无法写入宿主日志', failure)
|
|
200
|
+
hostLogError = failure
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (level === 'error') console.error('[html-workbench] ' + text, body || '')
|
|
77
204
|
}
|
|
78
205
|
|
|
79
206
|
// `CollectedOutput` carries more than text (truncation flags, spill paths);
|
|
@@ -86,6 +213,25 @@ return {
|
|
|
86
213
|
return trimmed
|
|
87
214
|
}
|
|
88
215
|
|
|
216
|
+
// `shell.resolve({ command })` takes ONE command line, so every embedded path
|
|
217
|
+
// must be quoted for the platform's parser. Naive `'"' + p + '"'` breaks on
|
|
218
|
+
// Windows, where `C:\dir\` ends in a backslash that escapes the closing quote
|
|
219
|
+
// and swallows the rest of the line. Never use JSON.stringify here: it emits
|
|
220
|
+
// JSON escapes (`\\`), which cmd.exe passes through verbatim, so Python
|
|
221
|
+
// receives a path with doubled separators and reports every file as missing.
|
|
222
|
+
const quoteArg = (value) => {
|
|
223
|
+
const raw = String(value == null ? '' : value)
|
|
224
|
+
if (IS_WINDOWS) {
|
|
225
|
+
// Double only the run of backslashes that precedes the closing quote;
|
|
226
|
+
// interior ones are literal to cmd.exe. Inner quotes cannot appear in a
|
|
227
|
+
// Windows path, but strip them rather than emit an unbalanced line.
|
|
228
|
+
const cleaned = raw.replace(/"/g, '')
|
|
229
|
+
const tail = cleaned.match(/\\*$/)[0]
|
|
230
|
+
return '"' + cleaned + tail + '"'
|
|
231
|
+
}
|
|
232
|
+
return "'" + raw.replace(/'/g, "'\\''") + "'"
|
|
233
|
+
}
|
|
234
|
+
|
|
89
235
|
const describeRun = (result) => {
|
|
90
236
|
if (!result) return 'shell.run threw'
|
|
91
237
|
const parts = []
|
|
@@ -97,13 +243,87 @@ return {
|
|
|
97
243
|
return parts.join(' ') + (streams.length ? '\n' + streams.join('\n') : '')
|
|
98
244
|
}
|
|
99
245
|
|
|
246
|
+
// Windows ships a `py` launcher that resolves versions the PATH may not
|
|
247
|
+
// expose. It is NOT usable as a command prefix (`py -3 script.py` mangles
|
|
248
|
+
// forwarded arguments), so ask it for the interpreter's absolute path and use
|
|
249
|
+
// that as a normal single-token executable.
|
|
250
|
+
const expandWindowsLauncher = async () => {
|
|
251
|
+
if (!IS_WINDOWS || !shell) return null
|
|
252
|
+
const probe = 'py -3 -c ' + quoteArg('import sys; print(sys.executable)')
|
|
253
|
+
try {
|
|
254
|
+
const result = await runShell(shell.resolve({ command: probe, timeoutMs: 5000, stdoutMaxBytes: 4096 }))
|
|
255
|
+
if (!result || result.exitCode !== 0) return { failure: 'py -3: ' + describeRun(result) }
|
|
256
|
+
const executable = outputText(result.stdout).split('\n').pop().trim()
|
|
257
|
+
if (!executable) return { failure: 'py -3: 没有返回解释器路径' }
|
|
258
|
+
return { command: quoteArg(executable) }
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return { failure: 'py -3: ' + ((e && e.message) || String(e)) }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const resolvePythonCommand = async () => {
|
|
265
|
+
if (pythonCommand) return pythonCommand
|
|
266
|
+
if (!shell) return null
|
|
267
|
+
// Collapse concurrent callers onto one probe: the panel's poll, an `open`
|
|
268
|
+
// and a keystroke-driven `resolve` can all arrive inside the same tick.
|
|
269
|
+
if (pythonProbe) return pythonProbe
|
|
270
|
+
if (pythonProbeFailedAt && Date.now() - pythonProbeFailedAt < PROBE_RETRY_MS) return null
|
|
271
|
+
pythonProbe = (async () => {
|
|
272
|
+
const failures = []
|
|
273
|
+
const unsupported = []
|
|
274
|
+
const checkVersion = async (command) => {
|
|
275
|
+
try {
|
|
276
|
+
const spec = shell.resolve({ command: command + ' --version', timeoutMs: 5000, stdoutMaxBytes: 4096 })
|
|
277
|
+
const result = await runShell(spec)
|
|
278
|
+
if (result && result.exitCode === 0) {
|
|
279
|
+
const output = outputText(result.stdout) + '\n' + outputText(result.stderr)
|
|
280
|
+
const version = /\bPython (\d+)\.(\d+)(?:\.\d+)?/.exec(output)
|
|
281
|
+
if (version && (Number(version[1]) > 3 || (version[1] === '3' && Number(version[2]) >= 9))) {
|
|
282
|
+
pythonCommand = command
|
|
283
|
+
pythonProbeFailedAt = 0
|
|
284
|
+
if (dependencyIssue?.code.startsWith('PYTHON_')) dependencyIssue = null
|
|
285
|
+
note('info', '使用 Python 解释器:' + command + '(' + version[0] + ')')
|
|
286
|
+
return true
|
|
287
|
+
}
|
|
288
|
+
if (version) unsupported.push(version[0])
|
|
289
|
+
}
|
|
290
|
+
failures.push(command + ': ' + describeRun(result))
|
|
291
|
+
} catch (e) {
|
|
292
|
+
failures.push(command + ': ' + ((e && e.message) || String(e)))
|
|
293
|
+
}
|
|
294
|
+
return false
|
|
295
|
+
}
|
|
296
|
+
for (const candidate of PYTHON_CANDIDATES) {
|
|
297
|
+
const command = opts.pythonCommand ? quoteArg(candidate) : candidate
|
|
298
|
+
if (await checkVersion(command)) return pythonCommand
|
|
299
|
+
}
|
|
300
|
+
const launcher = await expandWindowsLauncher()
|
|
301
|
+
if (launcher?.command && await checkVersion(launcher.command)) return pythonCommand
|
|
302
|
+
if (launcher && launcher.failure) failures.push(launcher.failure)
|
|
303
|
+
pythonProbeFailedAt = Date.now()
|
|
304
|
+
const versions = [...new Set(unsupported)].join('、')
|
|
305
|
+
startError = versions ? '检测到 ' + versions + ',版本过低。' : '找不到可用的 Python 3 解释器。'
|
|
306
|
+
startError += ' 请在运行 DSH 的电脑上安装 Python 3.9 或更高版本,确认 Python 已加入 PATH,然后重启 DSH。'
|
|
307
|
+
dependencyIssue = { code: versions ? 'PYTHON_TOO_OLD' : 'PYTHON_UNAVAILABLE', message: startError }
|
|
308
|
+
note('error', startError, failures.join('\n'))
|
|
309
|
+
return null
|
|
310
|
+
})()
|
|
311
|
+
try {
|
|
312
|
+
return await pythonProbe
|
|
313
|
+
} finally {
|
|
314
|
+
pythonProbe = null
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
100
318
|
const runCli = async (args, timeoutMs) => {
|
|
101
319
|
if (!shell) { note('error', 'shell 服务不可用,无法执行 workbench.py'); return null }
|
|
102
320
|
if (!SCRIPT) { note('error', 'workbench.py 路径未配置'); return null }
|
|
103
|
-
const
|
|
321
|
+
const python = await resolvePythonCommand()
|
|
322
|
+
if (!python) return null
|
|
323
|
+
const command = python + ' ' + quoteArg(SCRIPT) + ' ' + args
|
|
104
324
|
try {
|
|
105
325
|
const spec = shell.resolve({ command: command, timeoutMs: timeoutMs || 15000, stdoutMaxBytes: 64 * 1024 })
|
|
106
|
-
const result = await
|
|
326
|
+
const result = await runShell(spec)
|
|
107
327
|
// A non-zero exit is the interesting case and used to be discarded: the
|
|
108
328
|
// CLI prints a structured `{ok:false,error:{code,message}}` on stdout.
|
|
109
329
|
if (!result || result.exitCode !== 0) note('error', 'workbench.py ' + args.split(' ')[0] + ' 执行失败', describeRun(result))
|
|
@@ -116,15 +336,17 @@ return {
|
|
|
116
336
|
|
|
117
337
|
// `health` runs on a timer, so its failures are expected and must not flood
|
|
118
338
|
// the journal — poll quietly and let the caller decide what is worth noting.
|
|
119
|
-
const probeHealth = async () => {
|
|
339
|
+
const probeHealth = async (timeoutMs = 8000) => {
|
|
120
340
|
if (!shell || !SCRIPT) return null
|
|
341
|
+
const python = await resolvePythonCommand()
|
|
342
|
+
if (!python) return null
|
|
121
343
|
try {
|
|
122
344
|
const spec = shell.resolve({
|
|
123
|
-
command: '
|
|
124
|
-
timeoutMs:
|
|
345
|
+
command: python + ' ' + quoteArg(SCRIPT) + ' health --port ' + PORT,
|
|
346
|
+
timeoutMs: timeoutMs,
|
|
125
347
|
stdoutMaxBytes: 64 * 1024,
|
|
126
348
|
})
|
|
127
|
-
const result = await
|
|
349
|
+
const result = await runShell(spec)
|
|
128
350
|
if (!result || result.exitCode !== 0) return { ok: false, result: result }
|
|
129
351
|
try {
|
|
130
352
|
const payload = JSON.parse(outputText(result.stdout) || '')
|
|
@@ -137,9 +359,28 @@ return {
|
|
|
137
359
|
}
|
|
138
360
|
}
|
|
139
361
|
|
|
362
|
+
// `/api/health` advertises the directories the service really writes to. The
|
|
363
|
+
// paths it was ASKED for may be exactly what the sandbox refused, so adopting
|
|
364
|
+
// the reported ones is what lets the panel stay truthful — for a service we
|
|
365
|
+
// started and for one we merely reused (a reused service leaves us no startup
|
|
366
|
+
// output to read them from).
|
|
367
|
+
const adoptResolvedPaths = (payload) => {
|
|
368
|
+
if (!payload) return
|
|
369
|
+
serviceInfo = { version: payload.version || null, pythonVersion: payload.pythonVersion || null }
|
|
370
|
+
for (const key of ['platform', 'pid', 'pythonExecutable', 'logging']) {
|
|
371
|
+
if (key in payload) serviceInfo[key] = payload[key]
|
|
372
|
+
}
|
|
373
|
+
if ('logFile' in payload) serviceInfo.logFile = payload.logFile
|
|
374
|
+
const logDir = typeof payload.logDir === 'string' ? payload.logDir : null
|
|
375
|
+
const vendorCache = typeof payload.vendorCache === 'string' ? payload.vendorCache : null
|
|
376
|
+
if (logDir || vendorCache) resolvedPaths = { logDir: logDir, vendorCache: vendorCache }
|
|
377
|
+
}
|
|
378
|
+
|
|
140
379
|
const health = async () => {
|
|
141
380
|
const probe = await probeHealth()
|
|
142
|
-
|
|
381
|
+
if (!probe || !probe.ok) return null
|
|
382
|
+
adoptResolvedPaths(probe.payload)
|
|
383
|
+
return probe.payload
|
|
143
384
|
}
|
|
144
385
|
|
|
145
386
|
// `workbench.py` reports failures as one structured JSON line on stderr. Turn
|
|
@@ -147,14 +388,14 @@ return {
|
|
|
147
388
|
// in the detail so nothing is lost when the code is one we do not know.
|
|
148
389
|
const CLI_HINTS = {
|
|
149
390
|
PORT_IN_USE: '端口已被占用。可能有别的进程(或旧版服务)占着它 —— 点「重启服务」会先退役占用者。',
|
|
150
|
-
ASSET_NOT_FOUND: '
|
|
391
|
+
ASSET_NOT_FOUND: '缺少编辑器文件 assets/workbench.html。请重新安装 HTML Workbench 插件,然后重启 DSH。',
|
|
151
392
|
PYTHON_TOO_OLD: '需要 Python 3.9 或更高版本。',
|
|
152
393
|
SERVER_OUTDATED: '端口上有旧版服务且无法自动停止,请手动结束该进程。',
|
|
153
394
|
FILE_NOT_FOUND: '找不到文件。',
|
|
154
|
-
VENDOR_DOWNLOAD_FAILED: '
|
|
395
|
+
VENDOR_DOWNLOAD_FAILED: '无法准备编辑器所需的 GrapesJS。请检查运行 DSH 的电脑能否联网,以及缓存目录是否可写,然后在工作台中重启服务。',
|
|
155
396
|
}
|
|
156
397
|
|
|
157
|
-
const explainOutput = (text) => {
|
|
398
|
+
const explainOutput = (text, startup = false) => {
|
|
158
399
|
if (!text) return null
|
|
159
400
|
for (const line of String(text).split('\n')) {
|
|
160
401
|
const trimmed = line.trim()
|
|
@@ -162,8 +403,13 @@ return {
|
|
|
162
403
|
try {
|
|
163
404
|
const payload = JSON.parse(trimmed)
|
|
164
405
|
if (payload && payload.ok === false && payload.error) {
|
|
406
|
+
if ('logFile' in payload) startupLogFile = payload.logFile
|
|
165
407
|
const hint = CLI_HINTS[payload.error]
|
|
166
|
-
|
|
408
|
+
const message = hint || payload.message || payload.error
|
|
409
|
+
if (startup && ['VENDOR_DOWNLOAD_FAILED', 'ASSET_NOT_FOUND'].includes(payload.error)) {
|
|
410
|
+
dependencyIssue = { code: payload.error, message }
|
|
411
|
+
}
|
|
412
|
+
return message + '(' + payload.error + ')'
|
|
167
413
|
}
|
|
168
414
|
} catch (e) { /* not the JSON line; keep scanning */ }
|
|
169
415
|
}
|
|
@@ -186,13 +432,16 @@ return {
|
|
|
186
432
|
}
|
|
187
433
|
}
|
|
188
434
|
|
|
189
|
-
const
|
|
435
|
+
const startOrReuseService = async () => {
|
|
190
436
|
if (disposed) return null
|
|
437
|
+
serviceEpoch += 1
|
|
191
438
|
if (serviceRunning) return health()
|
|
192
439
|
const existing = await health()
|
|
440
|
+
if (disposed) return null
|
|
193
441
|
if (existing) {
|
|
194
442
|
serviceRunning = true
|
|
195
443
|
startError = null
|
|
444
|
+
dependencyIssue = null
|
|
196
445
|
healthConfirmedAt = Date.now()
|
|
197
446
|
// Record the reuse too, so the journal always explains the CURRENT state
|
|
198
447
|
// rather than only the failures.
|
|
@@ -201,26 +450,39 @@ return {
|
|
|
201
450
|
}
|
|
202
451
|
if (!shell) { startError = 'shell 服务不可用(DSH 未提供 shell,插件无法拉起本地服务)'; note('error', startError); return null }
|
|
203
452
|
if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; note('error', startError); return null }
|
|
453
|
+
const python = await resolvePythonCommand()
|
|
454
|
+
if (disposed || !python) return null
|
|
455
|
+
startupLogFile = undefined
|
|
204
456
|
let proc = null
|
|
205
|
-
const command = '
|
|
206
|
-
+ ' --log-dir
|
|
207
|
-
+ ' --vendor-cache
|
|
208
|
-
+ (EDITOR_ROOT ? ' --editor-root
|
|
457
|
+
const command = python + ' ' + quoteArg(SCRIPT) + ' serve --port ' + PORT
|
|
458
|
+
+ ' --log-dir ' + quoteArg(LOG_DIR)
|
|
459
|
+
+ ' --vendor-cache ' + quoteArg(VENDOR_CACHE)
|
|
460
|
+
+ (EDITOR_ROOT ? ' --editor-root ' + quoteArg(EDITOR_ROOT) : '')
|
|
209
461
|
try {
|
|
210
462
|
note('info', '启动本地服务:端口 ' + PORT, command)
|
|
211
|
-
proc =
|
|
463
|
+
proc = await startShell(shell.resolve({ command: command, stdoutMaxBytes: 64 * 1024 }))
|
|
212
464
|
if (disposed) { try { proc.kill() } catch (e) {} return null }
|
|
213
465
|
ownedProcess = proc
|
|
466
|
+
const deadline = Date.now() + 60000
|
|
214
467
|
let transcript = ''
|
|
215
|
-
|
|
468
|
+
let lastProbe = null
|
|
469
|
+
while (!disposed && Date.now() < deadline) {
|
|
216
470
|
const chunk = drain(proc)
|
|
217
|
-
if (chunk) transcript
|
|
218
|
-
const probe = await probeHealth()
|
|
219
|
-
|
|
471
|
+
if (chunk) transcript = (transcript + '\n' + chunk).trim().slice(-16000)
|
|
472
|
+
const probe = await probeHealth(Math.max(1, Math.min(8000, deadline - Date.now())))
|
|
473
|
+
lastProbe = probe
|
|
474
|
+
if (disposed) return null
|
|
475
|
+
if (probe && probe.ok && proc.status === 'running') {
|
|
220
476
|
serviceRunning = true
|
|
221
477
|
startError = null
|
|
478
|
+
dependencyIssue = null
|
|
222
479
|
healthConfirmedAt = Date.now()
|
|
480
|
+
adoptResolvedPaths(probe.payload)
|
|
223
481
|
note('info', '本地服务就绪:版本 ' + (probe.payload.version || '未知') + ',端口 ' + PORT)
|
|
482
|
+
if (probe.payload.logFile === null) {
|
|
483
|
+
const output = drain(proc)
|
|
484
|
+
note('warn', 'Python 服务未能启用文件日志', [transcript, output].filter(Boolean).join('\n'))
|
|
485
|
+
}
|
|
224
486
|
return probe.payload
|
|
225
487
|
}
|
|
226
488
|
// Stop waiting the moment the process is gone. Polling health for the
|
|
@@ -228,29 +490,41 @@ return {
|
|
|
228
490
|
// fast, loud failure look like a hang.
|
|
229
491
|
if (proc.status !== 'running') {
|
|
230
492
|
const tail = drain(proc)
|
|
231
|
-
if (tail) transcript
|
|
493
|
+
if (tail) transcript = (transcript + '\n' + tail).trim().slice(-16000)
|
|
232
494
|
// Lead with the CLI's own diagnosis when it gave one — "端口已被占用"
|
|
233
495
|
// is actionable, "exit=1" is not.
|
|
234
|
-
const explained = explainOutput(transcript)
|
|
235
|
-
|
|
236
|
-
|
|
496
|
+
const explained = explainOutput(transcript, true)
|
|
497
|
+
const exit = 'serve 进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')'
|
|
498
|
+
startError = explained || exit
|
|
499
|
+
note('error', startError, exit + '\n' + (transcript || '进程没有任何输出。命令:' + command))
|
|
237
500
|
ownedProcess = null
|
|
238
501
|
return null
|
|
239
502
|
}
|
|
240
|
-
|
|
503
|
+
const remaining = deadline - Date.now()
|
|
504
|
+
if (remaining > 0) await ctx.timeout(Math.min(250, remaining))
|
|
241
505
|
}
|
|
506
|
+
if (disposed) return null
|
|
242
507
|
startError = '服务在 60 秒内没有就绪(端口 ' + PORT + ' 未通过健康检查)'
|
|
243
|
-
|
|
508
|
+
const probeDetail = lastProbe && (lastProbe.error || describeRun(lastProbe.result))
|
|
509
|
+
note('error', startError, [transcript, probeDetail && '最后一次健康检查:' + probeDetail, command].filter(Boolean).join('\n'))
|
|
510
|
+
// The public executor leaves serve running until we stop it.
|
|
511
|
+
proc.kill()
|
|
512
|
+
ownedProcess = null
|
|
244
513
|
return null
|
|
245
514
|
} catch (e) {
|
|
246
515
|
startError = 'serve 进程启动失败:' + ((e && e.message) || String(e))
|
|
247
|
-
note('error', startError, command)
|
|
516
|
+
note('error', startError, [e && e.stack, command].filter(Boolean).join('\n'))
|
|
248
517
|
if (proc) { try { proc.kill() } catch (e2) {} }
|
|
249
518
|
ownedProcess = null
|
|
250
519
|
return null
|
|
251
520
|
}
|
|
252
521
|
}
|
|
253
522
|
|
|
523
|
+
const startService = () => {
|
|
524
|
+
if (!starting) starting = startOrReuseService().finally(() => { starting = null })
|
|
525
|
+
return starting
|
|
526
|
+
}
|
|
527
|
+
|
|
254
528
|
// A service that dies AFTER a successful start used to leave the plugin
|
|
255
529
|
// frozen: nothing ever re-checked the process, so the panel kept showing a
|
|
256
530
|
// stale dot while every open failed with no explanation. Reading
|
|
@@ -258,34 +532,40 @@ return {
|
|
|
258
532
|
// existing poll can supervise the process for free.
|
|
259
533
|
const superviseOwnedProcess = () => {
|
|
260
534
|
const proc = ownedProcess
|
|
261
|
-
if (!proc || proc.status === 'running') return
|
|
535
|
+
if (starting || !proc || proc.status === 'running') return
|
|
262
536
|
const tail = drain(proc)
|
|
263
537
|
const explained = explainOutput(tail)
|
|
264
538
|
ownedProcess = null
|
|
265
539
|
serviceRunning = false
|
|
266
540
|
startError = explained || ('服务进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
|
|
267
|
-
note('error', '服务进程意外退出', tail || '
|
|
541
|
+
note('error', '服务进程意外退出', [startError, tail || '进程没有留下输出。'].join('\n'))
|
|
268
542
|
}
|
|
269
543
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
// spawns a subprocess and the panel polls every few seconds. The window is
|
|
273
|
-
// seeded when the service is confirmed up, so a freshly verified service is
|
|
274
|
-
// not immediately re-checked.
|
|
544
|
+
// Periodically refresh health and log-write status for both owned and reused
|
|
545
|
+
// services. A live process handle alone cannot tell whether logging failed.
|
|
275
546
|
const REVALIDATE_MS = 10000
|
|
276
547
|
let healthConfirmedAt = 0
|
|
277
548
|
let revalidating = false
|
|
278
549
|
|
|
279
550
|
const revalidateService = () => {
|
|
280
|
-
if (
|
|
551
|
+
if (disposed || starting || restarting || revalidating || !serviceRunning) return
|
|
281
552
|
if (Date.now() - healthConfirmedAt < REVALIDATE_MS) return
|
|
553
|
+
const epoch = serviceEpoch
|
|
282
554
|
revalidating = true
|
|
283
555
|
void (async () => {
|
|
284
556
|
try {
|
|
285
|
-
|
|
557
|
+
const probe = await probeHealth()
|
|
558
|
+
// A delayed probe belongs to the service it checked, even if a restart
|
|
559
|
+
// has already finished. Never replace the new process's diagnostics.
|
|
560
|
+
if (disposed || epoch !== serviceEpoch) return
|
|
561
|
+
if (probe && probe.ok) {
|
|
562
|
+
adoptResolvedPaths(probe.payload)
|
|
563
|
+
healthConfirmedAt = Date.now()
|
|
564
|
+
return
|
|
565
|
+
}
|
|
286
566
|
serviceRunning = false
|
|
287
|
-
startError = '
|
|
288
|
-
note('error', '
|
|
567
|
+
startError = '本地服务已经不再响应(端口 ' + PORT + ')。'
|
|
568
|
+
note('error', '本地服务失去响应', probe && (probe.error || describeRun(probe.result)))
|
|
289
569
|
} finally {
|
|
290
570
|
revalidating = false
|
|
291
571
|
}
|
|
@@ -295,34 +575,54 @@ return {
|
|
|
295
575
|
// Everything the panel needs to explain a red dot without opening a terminal.
|
|
296
576
|
// Reaping first means a process that died since the last call is reported as
|
|
297
577
|
// dead here, instead of leaving a stale "running" dot behind.
|
|
578
|
+
const diagnosticFacts = () => ({
|
|
579
|
+
version: VERSION,
|
|
580
|
+
mode,
|
|
581
|
+
platform: PLATFORM,
|
|
582
|
+
arch,
|
|
583
|
+
nodeVersion,
|
|
584
|
+
shellAPI: shell ? ['resolve', 'execute', 'run', 'start'].filter(name => typeof shell[name] === 'function') : [],
|
|
585
|
+
port: PORT,
|
|
586
|
+
script: SCRIPT,
|
|
587
|
+
editorRoot: EDITOR_ROOT,
|
|
588
|
+
runtimeDir: RUNTIME_DIR,
|
|
589
|
+
pythonCommand,
|
|
590
|
+
pythonCandidates: PYTHON_CANDIDATES.slice(),
|
|
591
|
+
logDir: (resolvedPaths && resolvedPaths.logDir) || LOG_DIR,
|
|
592
|
+
vendorCache: (resolvedPaths && resolvedPaths.vendorCache) || VENDOR_CACHE,
|
|
593
|
+
hostLogFile: hostLog ? hostLog.file : null,
|
|
594
|
+
hostLogError,
|
|
595
|
+
service: serviceInfo,
|
|
596
|
+
startupLogFile,
|
|
597
|
+
hasShell: !!shell,
|
|
598
|
+
startError,
|
|
599
|
+
dependencyIssue,
|
|
600
|
+
journal: journal.slice().reverse(),
|
|
601
|
+
})
|
|
602
|
+
|
|
298
603
|
const diagnostics = () => {
|
|
299
604
|
superviseOwnedProcess()
|
|
300
605
|
revalidateService()
|
|
301
606
|
return {
|
|
607
|
+
...diagnosticFacts(),
|
|
302
608
|
ok: true,
|
|
303
609
|
running: serviceRunning,
|
|
304
610
|
owned: !!ownedProcess,
|
|
305
|
-
port: PORT,
|
|
306
|
-
script: SCRIPT,
|
|
307
|
-
editorRoot: EDITOR_ROOT,
|
|
308
|
-
runtimeDir: RUNTIME_DIR,
|
|
309
|
-
logDir: LOG_DIR,
|
|
310
|
-
vendorCache: VENDOR_CACHE,
|
|
311
|
-
hasShell: !!shell,
|
|
312
611
|
processStatus: ownedProcess ? ownedProcess.status : null,
|
|
313
612
|
exitCode: ownedProcess ? ownedProcess.exitCode : null,
|
|
314
|
-
startError: startError,
|
|
315
|
-
journal: journal.slice().reverse(),
|
|
316
613
|
}
|
|
317
614
|
}
|
|
318
615
|
|
|
319
616
|
// 生命周期副作用:卸载时停掉本插件自己启动的服务进程。
|
|
320
617
|
ctx.effect(() => () => {
|
|
321
618
|
disposed = true
|
|
619
|
+
serviceEpoch += 1
|
|
322
620
|
const proc = ownedProcess
|
|
323
621
|
ownedProcess = null
|
|
324
622
|
serviceRunning = false
|
|
325
|
-
if (proc) {
|
|
623
|
+
if (proc) {
|
|
624
|
+
try { proc.kill() } catch (e) { note('error', '卸载插件时无法停止服务', e.stack || e) }
|
|
625
|
+
}
|
|
326
626
|
})
|
|
327
627
|
|
|
328
628
|
const recordAsset = (path, kind) => {
|
|
@@ -347,7 +647,7 @@ return {
|
|
|
347
647
|
if (!/\.html?$/i.test(path)) return
|
|
348
648
|
recordAsset(path, exec.name === 'write' ? 'create' : 'edit')
|
|
349
649
|
} catch (e) {
|
|
350
|
-
|
|
650
|
+
note('error', '记录 HTML 产物时出错', e.stack || e)
|
|
351
651
|
}
|
|
352
652
|
})
|
|
353
653
|
|
|
@@ -357,12 +657,18 @@ return {
|
|
|
357
657
|
if (typeof file !== 'string' || !file) return { ok: false, error: '请填写文件路径' }
|
|
358
658
|
if (!/\.html?$/i.test(file)) return { ok: false, error: '只支持 .html / .htm 文件' }
|
|
359
659
|
if (!SCRIPT) return { ok: false, error: startError || 'workbench.py 路径未配置', diagnostics: diagnostics() }
|
|
360
|
-
const wasUp = await health()
|
|
361
|
-
|
|
362
|
-
const h = await health()
|
|
660
|
+
const wasUp = !starting && !restarting ? await health() : null
|
|
661
|
+
const h = restarting ? (await restarting).info : wasUp || await startService()
|
|
363
662
|
// Carry the diagnostics WITH the failure: the panel can then show the real
|
|
364
663
|
// cause instead of a generic "unavailable" that sends the user hunting.
|
|
365
|
-
if (!h)
|
|
664
|
+
if (!h) {
|
|
665
|
+
note('error', '打开编辑器时健康检查失败', startError)
|
|
666
|
+
return { ok: false, error: startError || '本地服务未就绪', diagnostics: diagnostics() }
|
|
667
|
+
}
|
|
668
|
+
adoptResolvedPaths(h)
|
|
669
|
+
serviceRunning = true
|
|
670
|
+
startError = null
|
|
671
|
+
healthConfirmedAt = Date.now()
|
|
366
672
|
return {
|
|
367
673
|
ok: true,
|
|
368
674
|
url: 'http://127.0.0.1:' + PORT + '/?file=' + encodeURIComponent(file),
|
|
@@ -372,12 +678,19 @@ return {
|
|
|
372
678
|
}
|
|
373
679
|
}
|
|
374
680
|
|
|
375
|
-
const
|
|
681
|
+
const stopAndRestartService = async () => {
|
|
682
|
+
serviceEpoch += 1
|
|
683
|
+
await starting
|
|
684
|
+
if (disposed) return { ok: false, error: 'HTML Workbench 已卸载' }
|
|
376
685
|
const proc = ownedProcess
|
|
377
686
|
ownedProcess = null
|
|
378
687
|
serviceRunning = false
|
|
379
688
|
startError = null
|
|
380
|
-
if (
|
|
689
|
+
if (dependencyIssue?.code.startsWith('PYTHON_')) pythonProbeFailedAt = 0
|
|
690
|
+
dependencyIssue = null
|
|
691
|
+
if (proc) {
|
|
692
|
+
try { proc.kill() } catch (e) { note('error', '重启前无法停止服务进程', e.stack || e) }
|
|
693
|
+
}
|
|
381
694
|
note('info', '手动重启本地服务')
|
|
382
695
|
// Killing our own handle is not enough when the port is held by a process
|
|
383
696
|
// we do not own (a leftover from a previous DSH run, or an outdated
|
|
@@ -391,6 +704,18 @@ return {
|
|
|
391
704
|
return h ? { ok: true, info: h, diagnostics: diagnostics() } : { ok: false, error: startError || '重启失败', diagnostics: diagnostics() }
|
|
392
705
|
}
|
|
393
706
|
|
|
707
|
+
const restartService = () => {
|
|
708
|
+
if (!restarting) restarting = stopAndRestartService().finally(() => { restarting = null })
|
|
709
|
+
return restarting
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const dependencyPayload = async () => {
|
|
713
|
+
const pending = restarting || starting
|
|
714
|
+
if (pending) await pending
|
|
715
|
+
else if (!serviceRunning && !startError) await startService()
|
|
716
|
+
return { ok: true, dependencyIssue }
|
|
717
|
+
}
|
|
718
|
+
|
|
394
719
|
// Browser address bars yield file:///… URLs while the CLI expects a native
|
|
395
720
|
// path. Normalize only for the local existence probe; the Python service
|
|
396
721
|
// repeats this normalization authoritatively for every request.
|
|
@@ -401,9 +726,8 @@ return {
|
|
|
401
726
|
const url = new URL(raw)
|
|
402
727
|
if (url.protocol !== 'file:' || url.search || url.hash) return raw
|
|
403
728
|
let path = decodeURIComponent(url.pathname)
|
|
404
|
-
|
|
405
|
-
if (
|
|
406
|
-
if (isWindows && url.hostname && url.hostname !== 'localhost') path = '//' + url.hostname + path
|
|
729
|
+
if (IS_WINDOWS && /^[\\/][A-Za-z]:\//.test(path)) path = path.slice(1)
|
|
730
|
+
if (IS_WINDOWS && url.hostname && url.hostname !== 'localhost') path = '//' + url.hostname + path
|
|
407
731
|
return path
|
|
408
732
|
} catch (e) {
|
|
409
733
|
return raw
|
|
@@ -416,14 +740,29 @@ return {
|
|
|
416
740
|
if (!trimmed) return { ok: true, empty: true, isHtml: false, exists: false }
|
|
417
741
|
const normalized = normalizeFileReference(trimmed)
|
|
418
742
|
if (!/\.html?$/i.test(normalized)) return { ok: true, isHtml: false, exists: false }
|
|
419
|
-
if (!shell) return { ok: true, isHtml: true, exists: null }
|
|
420
|
-
const
|
|
421
|
-
|
|
743
|
+
if (!shell) return { ok: true, isHtml: true, exists: null, normalized: normalized }
|
|
744
|
+
const python = await resolvePythonCommand()
|
|
745
|
+
// Say WHY the check could not run. Returning a bare `exists: null` renders
|
|
746
|
+
// as no indicator at all, which reads as "the field is ignoring me".
|
|
747
|
+
if (!python) {
|
|
748
|
+
return { ok: true, isHtml: true, exists: null, normalized: normalized, error: startError }
|
|
749
|
+
}
|
|
750
|
+
const probe = 'import os,sys; sys.exit(0 if os.path.isfile(sys.argv[1]) else 1)'
|
|
422
751
|
try {
|
|
423
|
-
const
|
|
424
|
-
|
|
752
|
+
const spec = shell.resolve({
|
|
753
|
+
command: python + ' -c ' + quoteArg(probe) + ' ' + quoteArg(normalized),
|
|
754
|
+
timeoutMs: 5000,
|
|
755
|
+
stdoutMaxBytes: 1024,
|
|
756
|
+
})
|
|
757
|
+
const r = await runShell(spec)
|
|
758
|
+
if (r && !r.timedOut && !r.aborted && !r.signal && [0, 1].includes(r.exitCode)) {
|
|
759
|
+
return { ok: true, isHtml: true, exists: r.exitCode === 0, normalized: normalized }
|
|
760
|
+
}
|
|
761
|
+
note('error', '文件检查未完成', describeRun(r))
|
|
762
|
+
return { ok: true, isHtml: true, exists: null, normalized, error: '文件检查未完成,请查看诊断日志。' }
|
|
425
763
|
} catch (e) {
|
|
426
|
-
|
|
764
|
+
note('error', '文件检查未完成', e.stack || e)
|
|
765
|
+
return { ok: true, isHtml: true, exists: null, normalized: normalized, error: (e && e.message) || String(e) }
|
|
427
766
|
}
|
|
428
767
|
}
|
|
429
768
|
|
|
@@ -433,14 +772,50 @@ return {
|
|
|
433
772
|
return base
|
|
434
773
|
}
|
|
435
774
|
|
|
775
|
+
const statusPayload = async () => {
|
|
776
|
+
const h = await health()
|
|
777
|
+
const out = diagnostics()
|
|
778
|
+
out.ok = !!h
|
|
779
|
+
out.running = !!h
|
|
780
|
+
out.info = h || null
|
|
781
|
+
return out
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// `diagnostics()` supervises the child process and revalidates the service,
|
|
785
|
+
// so it can itself throw (a dead handle, a shell that rejects). Since it is
|
|
786
|
+
// the payload every error path attaches, it must never be the thing that
|
|
787
|
+
// turns a readable failure back into an opaque one.
|
|
788
|
+
const safeDiagnostics = () => {
|
|
789
|
+
try {
|
|
790
|
+
return diagnostics()
|
|
791
|
+
} catch (error) {
|
|
792
|
+
return {
|
|
793
|
+
...diagnosticFacts(),
|
|
794
|
+
ok: false,
|
|
795
|
+
degraded: true,
|
|
796
|
+
error: '收集诊断信息时出错:' + ((error && error.message) || String(error)),
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
436
801
|
// 动态传输:Package-private RPC(仅动态运行时存在 harness)。
|
|
437
802
|
if (typeof harness !== 'undefined') {
|
|
438
|
-
harness.handle(
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
803
|
+
const handle = (name, handler) => harness.handle(name, async (args) => {
|
|
804
|
+
try {
|
|
805
|
+
return await handler(args)
|
|
806
|
+
} catch (error) {
|
|
807
|
+
const reason = (error && error.stack) || String(error)
|
|
808
|
+
note('error', 'RPC ' + name + ' 处理失败', reason)
|
|
809
|
+
return { ok: false, error: (error && error.message) || String(error), diagnostics: safeDiagnostics() }
|
|
810
|
+
}
|
|
811
|
+
})
|
|
812
|
+
handle('list', listPayload)
|
|
813
|
+
handle('status', statusPayload)
|
|
814
|
+
handle('open', (args) => openFile(args && args.file))
|
|
815
|
+
handle('resolve', (args) => resolvePath(args && args.file))
|
|
816
|
+
handle('diagnostics', safeDiagnostics)
|
|
817
|
+
handle('dependencies', dependencyPayload)
|
|
818
|
+
handle('restart', restartService)
|
|
444
819
|
}
|
|
445
820
|
|
|
446
821
|
// 静态传输:HTTP 路由,供静态 client bundle(fetch)与直接浏览器访问使用。
|
|
@@ -462,58 +837,84 @@ return {
|
|
|
462
837
|
})
|
|
463
838
|
return out
|
|
464
839
|
}
|
|
840
|
+
// A handler that throws leaves the request hanging until the browser gives
|
|
841
|
+
// up, and `fetch` then rejects with a bare "Failed to fetch" — the exact
|
|
842
|
+
// dead end this plugin's users hit. Always answer, and answer with the
|
|
843
|
+
// reason plus the journal, so the panel can render something actionable.
|
|
844
|
+
const guard = (name, handler) => async (req, res) => {
|
|
845
|
+
try {
|
|
846
|
+
await handler(req, res)
|
|
847
|
+
} catch (error) {
|
|
848
|
+
const reason = (error && error.stack) || (error && error.message) || String(error)
|
|
849
|
+
note('error', '路由 ' + name + ' 处理失败', reason)
|
|
850
|
+
try {
|
|
851
|
+
sendJson(res, 500, {
|
|
852
|
+
ok: false,
|
|
853
|
+
error: '插件内部错误(' + name + '):' + ((error && error.message) || String(error)),
|
|
854
|
+
detail: reason,
|
|
855
|
+
diagnostics: safeDiagnostics(),
|
|
856
|
+
})
|
|
857
|
+
} catch (_) { /* the socket is already gone; the journal has the reason */ }
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// Registered FIRST and deliberately dependency-free: if anything below
|
|
861
|
+
// fails to register, this route still answers, which is the only way to
|
|
862
|
+
// read the failure without access to the terminal that started DSH.
|
|
863
|
+
ctx.effect(() => webServer.register({
|
|
864
|
+
kind: 'exact',
|
|
865
|
+
path: '/html-workbench/diagnostics',
|
|
866
|
+
handler: guard('diagnostics', (req, res) => sendJson(res, 200, safeDiagnostics())),
|
|
867
|
+
}), 'html-workbench: diagnostics route')
|
|
465
868
|
ctx.effect(() => webServer.register({
|
|
466
869
|
kind: 'exact',
|
|
467
870
|
path: '/html-workbench/list',
|
|
468
|
-
handler: (req, res) => sendJson(res, 200, listPayload()),
|
|
871
|
+
handler: guard('list', (req, res) => sendJson(res, 200, listPayload())),
|
|
469
872
|
}), 'html-workbench: list route')
|
|
873
|
+
ctx.effect(() => webServer.register({
|
|
874
|
+
kind: 'exact',
|
|
875
|
+
path: '/html-workbench/dependencies',
|
|
876
|
+
handler: guard('dependencies', async (req, res) => sendJson(res, 200, await dependencyPayload())),
|
|
877
|
+
}), 'html-workbench: dependencies route')
|
|
470
878
|
ctx.effect(() => webServer.register({
|
|
471
879
|
kind: 'exact',
|
|
472
880
|
path: '/html-workbench/status',
|
|
473
|
-
handler: async (req, res) =>
|
|
474
|
-
const h = await health()
|
|
475
|
-
const out = diagnostics()
|
|
476
|
-
out.ok = !!h
|
|
477
|
-
out.running = !!h
|
|
478
|
-
out.info = h || null
|
|
479
|
-
sendJson(res, 200, out)
|
|
480
|
-
},
|
|
881
|
+
handler: guard('status', async (req, res) => sendJson(res, 200, await statusPayload())),
|
|
481
882
|
}), 'html-workbench: status route')
|
|
482
883
|
ctx.effect(() => webServer.register({
|
|
483
884
|
kind: 'exact',
|
|
484
885
|
path: '/html-workbench/open',
|
|
485
|
-
handler: async (req, res) => {
|
|
886
|
+
handler: guard('open', async (req, res) => {
|
|
486
887
|
const file = parseQuery(req).file
|
|
487
888
|
const out = await openFile(file)
|
|
488
889
|
sendJson(res, out.ok ? 200 : 400, out)
|
|
489
|
-
},
|
|
890
|
+
}),
|
|
490
891
|
}), 'html-workbench: open route')
|
|
491
892
|
ctx.effect(() => webServer.register({
|
|
492
893
|
kind: 'exact',
|
|
493
894
|
path: '/html-workbench/resolve',
|
|
494
|
-
handler: async (req, res) => {
|
|
895
|
+
handler: guard('resolve', async (req, res) => {
|
|
495
896
|
const out = await resolvePath(parseQuery(req).file)
|
|
496
897
|
sendJson(res, out.ok ? 200 : 400, out)
|
|
497
|
-
},
|
|
898
|
+
}),
|
|
498
899
|
}), 'html-workbench: resolve route')
|
|
499
|
-
// Readable straight from a browser tab — the fastest path from "red dot"
|
|
500
|
-
// to "here is the traceback" without any UI in the way.
|
|
501
|
-
ctx.effect(() => webServer.register({
|
|
502
|
-
kind: 'exact',
|
|
503
|
-
path: '/html-workbench/diagnostics',
|
|
504
|
-
handler: (req, res) => sendJson(res, 200, diagnostics()),
|
|
505
|
-
}), 'html-workbench: diagnostics route')
|
|
506
900
|
ctx.effect(() => webServer.register({
|
|
507
901
|
kind: 'exact',
|
|
508
902
|
path: '/html-workbench/restart',
|
|
509
|
-
handler: async (req, res) => {
|
|
903
|
+
handler: guard('restart', async (req, res) => {
|
|
510
904
|
const out = await restartService()
|
|
511
905
|
sendJson(res, out.ok ? 200 : 500, out)
|
|
512
|
-
},
|
|
906
|
+
}),
|
|
513
907
|
}), 'html-workbench: restart route')
|
|
514
908
|
}
|
|
515
909
|
|
|
516
910
|
// 注册时主动拉起服务(异步,不阻塞 apply)。
|
|
517
|
-
|
|
911
|
+
// A rejection here must not escape into Cordis: `apply` has already wired up
|
|
912
|
+
// the transports, and losing them would take the diagnostics route down with
|
|
913
|
+
// it — leaving the panel with nothing but "Failed to fetch" again.
|
|
914
|
+
note('info', '插件宿主已加载', JSON.stringify({ mode, platform: PLATFORM, arch, nodeVersion, script: SCRIPT, runtimeDir: RUNTIME_DIR }))
|
|
915
|
+
Promise.resolve().then(() => restarting ? null : startService()).catch((error) => {
|
|
916
|
+
startError = '启动本地服务时发生未预期的错误:' + ((error && error.message) || String(error))
|
|
917
|
+
note('error', startError, (error && error.stack) || null)
|
|
918
|
+
})
|
|
518
919
|
},
|
|
519
920
|
}
|