dsh-gufa-code 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +93 -2
- package/client.js +11548 -0
- package/cordis.patch.yml +8 -0
- package/docs/icon-180.png +0 -0
- package/docs/icon.svg +18 -0
- package/docs/market-submission.md +32 -0
- package/docs/screenshot-ide.jpg +0 -0
- package/host/assets.js +31 -0
- package/host/chat.js +523 -0
- package/host/context.js +438 -0
- package/host/dev.js +35 -0
- package/host/fs.js +306 -0
- package/host/git.js +532 -0
- package/host/project.js +305 -0
- package/host/ssh.js +54 -0
- package/host/terminal-pty.js +237 -0
- package/host/terminal.js +947 -0
- package/icon.svg +18 -0
- package/index.js +63 -0
- package/locale/en.json +121 -0
- package/locale/zh.json +121 -0
- package/package.json +67 -4
- package/screenshots.json +3 -0
- package/scripts/build-client.mjs +38 -0
- package/scripts/selftest-client.mjs +457 -0
- package/scripts/selftest-host.mjs +197 -0
- package/scripts/selftest-http.mjs +284 -0
- package/scripts/selftest-router.mjs +101 -0
- package/scripts/selftest-terminal-client.mjs +314 -0
- package/scripts/selftest-terminal-pty.mjs +190 -0
- package/scripts/selftest-terminal.mjs +351 -0
- package/scripts/test-chat-render.mjs +702 -0
- package/scripts/test-chat.mjs +761 -0
- package/scripts/test-editor.mjs +516 -0
- package/scripts/verify-workspace-sessions.mjs +135 -0
- package/src/client/00-core.js +1686 -0
- package/src/client/10-icons.js +205 -0
- package/src/client/20-editor.js +1318 -0
- package/src/client/30-terminal.js +1510 -0
- package/src/client/40-chat.js +1184 -0
- package/src/client/46-gitgraph.js +278 -0
- package/src/client/50-panels.js +916 -0
- package/src/client/55-backdrop.js +508 -0
- package/src/client/60-dock.js +662 -0
- package/src/client/85-shortcuts.js +516 -0
- package/src/client/90-app.js +2272 -0
- package/src/client/99-main.js +449 -0
- package/vendor/monaco/LICENSE +21 -0
- package/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/vendor/monaco/vs/loader.js +11 -0
- package/vendor/monaco/vs/nls.messages.de.js +21 -0
- package/vendor/monaco/vs/nls.messages.es.js +21 -0
- package/vendor/monaco/vs/nls.messages.fr.js +19 -0
- package/vendor/monaco/vs/nls.messages.it.js +19 -0
- package/vendor/monaco/vs/nls.messages.ja.js +21 -0
- package/vendor/monaco/vs/nls.messages.ko.js +19 -0
- package/vendor/monaco/vs/nls.messages.ru.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
- package/vendor/xterm/LICENSE +21 -0
- package/vendor/xterm/LICENSE.addon-fit +19 -0
- package/vendor/xterm/addon-fit.js +2 -0
- package/vendor/xterm/xterm.css +285 -0
- package/vendor/xterm/xterm.js +2 -0
package/host/terminal.js
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 终端能力:真实 PTY + WebSocket 桥 + 断线重连回放。
|
|
3
|
+
*
|
|
4
|
+
* 架构:
|
|
5
|
+
* - PTY 由 DSH Host 服务 `ctx.subprocess.spawnTerminal()` 分配(本地实现走 node-pty,
|
|
6
|
+
* 但不直接依赖 node-pty 包)。handle 约定:output(Node stream) / write(data) /
|
|
7
|
+
* resize(cols, rows) / terminate() / done({ exitCode, signal })。
|
|
8
|
+
* - 终端实例按项目 root 分组保存在内存 Map(每组最多 8 个),WebSocket 断开不 kill PTY;
|
|
9
|
+
* 只有 `terminal.close` 或插件 dispose 才终止进程。
|
|
10
|
+
* - 每个终端保留最近 2MB 输出用于回放;客户端重连时带 `since=<已收字节数>`,
|
|
11
|
+
* 服务端只补发缺失部分;缓冲已滑出窗口时先发 `{type:'reset'}` 再发完整回放。
|
|
12
|
+
*
|
|
13
|
+
* WS 协议(/gufa-code/ws?channel=terminal&root=<enc>&id=<id>&since=<n>):
|
|
14
|
+
* 客户端 → { type:'input', data } / { type:'resize', cols, rows }
|
|
15
|
+
* 服务端 → { type:'output', data, offset?, replay? } / { type:'reset' } /
|
|
16
|
+
* { type:'exit', code, signal? } / { type:'error', message }
|
|
17
|
+
* offset 是该段文本在终端输出流中的绝对 UTF-8 字节偏移,客户端据此校准 since。
|
|
18
|
+
*/
|
|
19
|
+
import { appendFileSync } from 'node:fs'
|
|
20
|
+
import { randomUUID } from 'node:crypto'
|
|
21
|
+
import { WebSocketServer } from 'ws'
|
|
22
|
+
import { ApiError, parseWsJson, wsJson } from './context.js'
|
|
23
|
+
import {
|
|
24
|
+
clampInteger,
|
|
25
|
+
DEFAULT_COLS,
|
|
26
|
+
DEFAULT_ROWS,
|
|
27
|
+
filteredEnvironment,
|
|
28
|
+
MAX_COLS,
|
|
29
|
+
MAX_ROWS,
|
|
30
|
+
MAX_TERMINALS_PER_ROOT,
|
|
31
|
+
MIN_COLS,
|
|
32
|
+
MIN_ROWS,
|
|
33
|
+
ReplayBuffer,
|
|
34
|
+
resolveShellPath,
|
|
35
|
+
TERMINAL_GRACE_MS,
|
|
36
|
+
TERMINAL_TYPE,
|
|
37
|
+
terminalShellArgs,
|
|
38
|
+
terminalTitleFor,
|
|
39
|
+
} from './terminal-pty.js'
|
|
40
|
+
|
|
41
|
+
/** 诊断日志:桌面壳的页面在 dsh-app:// 下,排查 WS 问题时把关键信息落到本机文件。 */
|
|
42
|
+
const DEBUG_LOG_PATH = '/tmp/gufa-code-debug.log'
|
|
43
|
+
function debugLog(...parts) {
|
|
44
|
+
try {
|
|
45
|
+
const line = `[${new Date().toISOString()}] ${parts
|
|
46
|
+
.map((part) => (typeof part === 'string' ? part : JSON.stringify(part)))
|
|
47
|
+
.join(' ')}
|
|
48
|
+
`
|
|
49
|
+
appendFileSync(DEBUG_LOG_PATH, line, { mode: 0o600 })
|
|
50
|
+
} catch {
|
|
51
|
+
/* 调试日志失败不影响功能 */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 终端 WS 的短期票据:页面通过已鉴权的 HTTP API 取票,桌面壳(dsh-app://)下用它替代 Origin 围栏。 */
|
|
56
|
+
const WS_TICKET_TTL_MS = 120_000
|
|
57
|
+
/** ws 的 OPEN 常量(不 import 具体实现,避免与 context.js 的 ws 版本耦合)。 */
|
|
58
|
+
const WS_OPEN = 1
|
|
59
|
+
/** 单条 WS 积压超过该值时跳过输出帧,防止 `yes` 之类刷屏把内存打满。 */
|
|
60
|
+
const MAX_WS_BUFFERED_BYTES = 8 * 1024 * 1024
|
|
61
|
+
/** 单次键盘输入上限(字节),异常巨大的输入直接忽略。 */
|
|
62
|
+
const MAX_INPUT_BYTES = 1024 * 1024
|
|
63
|
+
/** 心跳间隔:保活本地长连接。 */
|
|
64
|
+
const HEARTBEAT_MS = 25_000
|
|
65
|
+
/** 显式关闭终端时等待 terminate 完成的上限。 */
|
|
66
|
+
const CLOSE_WAIT_MS = 12_000
|
|
67
|
+
|
|
68
|
+
/** 取得 subprocess 服务(缺失时给出可读错误,而不是崩溃)。 */
|
|
69
|
+
function requireSubprocess(env) {
|
|
70
|
+
const service = env.ctx?.get?.('subprocess')
|
|
71
|
+
if (service === undefined || service === null) {
|
|
72
|
+
throw new ApiError('terminal-unavailable', '终端服务不可用:ctx.subprocess 尚未加载', 503)
|
|
73
|
+
}
|
|
74
|
+
if (typeof service.spawnTerminal !== 'function') {
|
|
75
|
+
throw new ApiError('terminal-unavailable', '终端服务未实现 spawnTerminal(缺少本地 PTY 后端)', 503)
|
|
76
|
+
}
|
|
77
|
+
return service
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 给 promise 加超时:超时后照常返回(不 reject),用于关闭路径兜底。 */
|
|
81
|
+
function settleWithTimeout(promise, timeoutMs) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
let done = false
|
|
84
|
+
const finish = () => {
|
|
85
|
+
if (done) return
|
|
86
|
+
done = true
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
resolve()
|
|
89
|
+
}
|
|
90
|
+
const timer = setTimeout(finish, timeoutMs)
|
|
91
|
+
timer.unref?.()
|
|
92
|
+
Promise.resolve(promise).then(finish, finish)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 读取 provider 的默认 shell(不可用时返回 undefined)。 */
|
|
97
|
+
async function detectPreferredShell(subprocess, signal) {
|
|
98
|
+
if (typeof subprocess.terminalEnvironment !== 'function') return undefined
|
|
99
|
+
try {
|
|
100
|
+
const info = await subprocess.terminalEnvironment(signal)
|
|
101
|
+
return typeof info?.defaultShell === 'string' ? info.defaultShell : undefined
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 一个终端实例:PTY handle + 回放缓冲 + WS 订阅者。 */
|
|
108
|
+
class TerminalSession {
|
|
109
|
+
#decoder = new TextDecoder('utf-8')
|
|
110
|
+
#disposePromise = null
|
|
111
|
+
|
|
112
|
+
/** @param {{ id: string, root: string, title: string, shell: string, handle: object, cols: number, rows: number }} options */
|
|
113
|
+
constructor(options) {
|
|
114
|
+
this.id = options.id
|
|
115
|
+
this.root = options.root
|
|
116
|
+
this.title = options.title
|
|
117
|
+
this.shell = options.shell
|
|
118
|
+
this.handle = options.handle
|
|
119
|
+
this.cols = options.cols
|
|
120
|
+
this.rows = options.rows
|
|
121
|
+
this.createdAt = Date.now()
|
|
122
|
+
this.exited = false
|
|
123
|
+
this.exitCode = null
|
|
124
|
+
this.exitSignal = null
|
|
125
|
+
this.closed = false
|
|
126
|
+
this.replay = new ReplayBuffer()
|
|
127
|
+
/** @type {Set<object>} */
|
|
128
|
+
this.subscribers = new Set()
|
|
129
|
+
this.loggedFirstOutput = false
|
|
130
|
+
this.loggedFirstInput = false
|
|
131
|
+
this.onData = (chunk) => this.receive(chunk)
|
|
132
|
+
this.onEnd = () => this.flushDecoder()
|
|
133
|
+
this.onError = (error) => this.fail(error)
|
|
134
|
+
this.handle.output?.on?.('data', this.onData)
|
|
135
|
+
this.handle.output?.once?.('end', this.onEnd)
|
|
136
|
+
this.handle.output?.once?.('error', this.onError)
|
|
137
|
+
if (this.handle.done !== undefined) {
|
|
138
|
+
this.handle.done.then(
|
|
139
|
+
(outcome) => this.settle(outcome?.exitCode ?? null, outcome?.signal ?? null),
|
|
140
|
+
(error) => this.fail(error),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 供 terminal.list 使用的公开描述。 */
|
|
146
|
+
toInfo() {
|
|
147
|
+
return {
|
|
148
|
+
id: this.id,
|
|
149
|
+
title: this.title,
|
|
150
|
+
shell: this.shell,
|
|
151
|
+
cols: this.cols,
|
|
152
|
+
rows: this.rows,
|
|
153
|
+
alive: !this.exited,
|
|
154
|
+
exitCode: this.exitCode,
|
|
155
|
+
createdAt: this.createdAt,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 收到一段 PTY 输出:写入回放并广播。 */
|
|
160
|
+
receive(chunk) {
|
|
161
|
+
if (this.closed) return
|
|
162
|
+
if (this.loggedFirstOutput !== true) {
|
|
163
|
+
this.loggedFirstOutput = true
|
|
164
|
+
debugLog('pty-first-output', { id: this.id, shell: this.shell, bytes: chunk?.length ?? null })
|
|
165
|
+
}
|
|
166
|
+
let text
|
|
167
|
+
try {
|
|
168
|
+
text = this.#decoder.decode(chunk, { stream: true })
|
|
169
|
+
} catch {
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (text === '') return
|
|
173
|
+
const offset = this.replay.totalBytes
|
|
174
|
+
this.replay.push(text)
|
|
175
|
+
this.broadcast({ type: 'output', data: text, offset })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** PTY 流结束时把解码器里可能残留的多字节字符吐出来。 */
|
|
179
|
+
flushDecoder() {
|
|
180
|
+
if (this.closed) return
|
|
181
|
+
let tail = ''
|
|
182
|
+
try {
|
|
183
|
+
tail = this.#decoder.decode()
|
|
184
|
+
} catch {
|
|
185
|
+
tail = ''
|
|
186
|
+
}
|
|
187
|
+
if (tail === '') return
|
|
188
|
+
const offset = this.replay.totalBytes
|
|
189
|
+
this.replay.push(tail)
|
|
190
|
+
this.broadcast({ type: 'output', data: tail, offset })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 向 PTY 写入键盘输入。 */
|
|
194
|
+
write(data) {
|
|
195
|
+
if (this.loggedFirstInput !== true) {
|
|
196
|
+
this.loggedFirstInput = true
|
|
197
|
+
debugLog('pty-first-input', { id: this.id, len: typeof data === 'string' ? data.length : null, exited: this.exited, closed: this.closed })
|
|
198
|
+
}
|
|
199
|
+
if (this.exited || this.closed) return
|
|
200
|
+
try {
|
|
201
|
+
const result = this.handle.write(data)
|
|
202
|
+
if (result !== undefined && typeof result.then === 'function') result.catch((error) => this.fail(error))
|
|
203
|
+
} catch (error) {
|
|
204
|
+
this.fail(error)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 调整 PTY 尺寸。 */
|
|
209
|
+
resize(cols, rows) {
|
|
210
|
+
this.cols = cols
|
|
211
|
+
this.rows = rows
|
|
212
|
+
if (this.exited || this.closed) return
|
|
213
|
+
try {
|
|
214
|
+
const result = this.handle.resize(cols, rows)
|
|
215
|
+
if (result !== undefined && typeof result.then === 'function') result.catch(() => {})
|
|
216
|
+
} catch {
|
|
217
|
+
/* 进程可能恰好退出;尺寸只是尽力而为 */
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 广播一帧给所有 WS 订阅者。 */
|
|
222
|
+
broadcast(payload) {
|
|
223
|
+
if (this.broadcastCount === undefined) this.broadcastCount = 0
|
|
224
|
+
this.broadcastCount += 1
|
|
225
|
+
if (this.broadcastCount <= 3) {
|
|
226
|
+
debugLog('pty-broadcast', { id: this.id, n: this.broadcastCount, type: payload?.type, subscribers: this.subscribers.size, bytes: typeof payload?.data === 'string' ? payload.data.length : null })
|
|
227
|
+
}
|
|
228
|
+
for (const connection of this.subscribers) {
|
|
229
|
+
try {
|
|
230
|
+
if (connection.readyState !== WS_OPEN) continue
|
|
231
|
+
if ((connection.bufferedAmount ?? 0) > MAX_WS_BUFFERED_BYTES) continue
|
|
232
|
+
wsJson(connection, payload)
|
|
233
|
+
} catch {
|
|
234
|
+
/* 单个连接异常不影响其他订阅者 */
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 新 WS 连接接入:按 since 补发缺失输出。
|
|
241
|
+
* @param {object} connection
|
|
242
|
+
* @param {number|null} since 客户端已收到的绝对字节偏移;null 表示首次连接
|
|
243
|
+
*/
|
|
244
|
+
attach(connection, since) {
|
|
245
|
+
debugLog('ws-attach', { id: this.id, since, closed: this.closed, exited: this.exited, replayBytes: this.replay.totalBytes, subscribers: this.subscribers.size + 1 })
|
|
246
|
+
this.subscribers.add(connection)
|
|
247
|
+
if (since === null) {
|
|
248
|
+
const text = this.replay.toString()
|
|
249
|
+
if (text !== '') {
|
|
250
|
+
wsJson(connection, { type: 'output', data: text, replay: true, offset: this.replay.retainedStart })
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
const slice = this.replay.sliceFrom(since)
|
|
254
|
+
if (slice.lossy) {
|
|
255
|
+
// 缓冲窗口已滑走:让客户端清屏后接收完整回放,避免花屏/重复。
|
|
256
|
+
wsJson(connection, { type: 'reset' })
|
|
257
|
+
const text = this.replay.toString()
|
|
258
|
+
if (text !== '') {
|
|
259
|
+
wsJson(connection, { type: 'output', data: text, replay: true, offset: this.replay.retainedStart })
|
|
260
|
+
}
|
|
261
|
+
} else if (slice.text !== '') {
|
|
262
|
+
wsJson(connection, { type: 'output', data: slice.text, replay: true, offset: since })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (this.exited) wsJson(connection, { type: 'exit', code: this.exitCode, signal: this.exitSignal })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
detach(connection) {
|
|
269
|
+
this.subscribers.delete(connection)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 记录退出(幂等)。 */
|
|
273
|
+
settle(exitCode, signal) {
|
|
274
|
+
if (this.exited) return
|
|
275
|
+
this.flushDecoder()
|
|
276
|
+
this.exited = true
|
|
277
|
+
this.exitCode = typeof exitCode === 'number' ? exitCode : null
|
|
278
|
+
this.exitSignal = typeof signal === 'string' ? signal : null
|
|
279
|
+
this.broadcast({ type: 'exit', code: this.exitCode, signal: this.exitSignal })
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** PTY 传输失败:先告知客户端,再按退出处理。 */
|
|
283
|
+
fail(error) {
|
|
284
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
285
|
+
if (!this.closed) this.broadcast({ type: 'error', message: message.slice(0, 2000) })
|
|
286
|
+
this.settle(null, null)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 终止 PTY 并释放监听(幂等,供 terminal.close / dispose 调用)。 */
|
|
290
|
+
dispose() {
|
|
291
|
+
if (this.#disposePromise !== null) return this.#disposePromise
|
|
292
|
+
debugLog('session-dispose', { id: this.id, exited: this.exited })
|
|
293
|
+
this.closed = true
|
|
294
|
+
// 主动关闭已连上的 WS:客户端据此停止重连(host 热重载 / 显式关闭时避免僵尸连接)。
|
|
295
|
+
for (const connection of this.subscribers) {
|
|
296
|
+
try {
|
|
297
|
+
wsJson(connection, { type: 'error', message: '终端已关闭,请新建终端' })
|
|
298
|
+
connection.close(1010, 'terminal disposed')
|
|
299
|
+
} catch {
|
|
300
|
+
/* 连接可能已在关闭中 */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
this.subscribers.clear()
|
|
304
|
+
try {
|
|
305
|
+
this.handle.output?.off?.('data', this.onData)
|
|
306
|
+
this.handle.output?.off?.('end', this.onEnd)
|
|
307
|
+
this.handle.output?.off?.('error', this.onError)
|
|
308
|
+
} catch {
|
|
309
|
+
/* 流可能已销毁 */
|
|
310
|
+
}
|
|
311
|
+
this.#disposePromise = (async () => {
|
|
312
|
+
try {
|
|
313
|
+
if (typeof this.handle.terminate === 'function') {
|
|
314
|
+
await settleWithTimeout(this.handle.terminate(), CLOSE_WAIT_MS)
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
/* PTY 可能已经退出 */
|
|
318
|
+
}
|
|
319
|
+
})()
|
|
320
|
+
return this.#disposePromise
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 注册终端能力。由 index.js 在插件 apply 时调用。
|
|
326
|
+
* @param {ReturnType<import('./context.js').createRouter>} router
|
|
327
|
+
* @param {{ ctx: object, projects: import('./context.js').ProjectRegistry }} env
|
|
328
|
+
*/
|
|
329
|
+
export function register(router, env) {
|
|
330
|
+
/* 注意:活动终端注册表挂在 globalThis 上,Host 模块热重载(改 host/*.js)后
|
|
331
|
+
新代仍能列出/接管旧代创建的 PTY,不会再把用户正在用的终端「关掉」。 */
|
|
332
|
+
const globalStore = (() => {
|
|
333
|
+
const key = '__gufaTerminalStore'
|
|
334
|
+
if (globalThis[key] === undefined || globalThis[key] === null) globalThis[key] = { groups: new Map(), sessions: new Set() }
|
|
335
|
+
return globalThis[key]
|
|
336
|
+
})()
|
|
337
|
+
if (!(globalStore.groups instanceof Map)) globalStore.groups = new Map()
|
|
338
|
+
if (!(globalStore.sessions instanceof Set)) globalStore.sessions = new Set()
|
|
339
|
+
/** @type {Map<string, Map<string, TerminalSession>>} root → (id → session) */
|
|
340
|
+
const groups = globalStore.groups
|
|
341
|
+
/** @type {Set<TerminalSession>} 全部活动终端(含已退出但保留回放的) */
|
|
342
|
+
const sessions = globalStore.sessions
|
|
343
|
+
let disposed = false
|
|
344
|
+
|
|
345
|
+
const groupFor = (root) => {
|
|
346
|
+
let group = groups.get(root)
|
|
347
|
+
if (group === undefined) {
|
|
348
|
+
group = new Map()
|
|
349
|
+
groups.set(root, group)
|
|
350
|
+
}
|
|
351
|
+
return group
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const findSession = (root, id) => {
|
|
355
|
+
if (typeof id !== 'string' || id === '') return undefined
|
|
356
|
+
if (typeof root === 'string' && root !== '') {
|
|
357
|
+
const found = groups.get(root)?.get(id)
|
|
358
|
+
if (found !== undefined) return found
|
|
359
|
+
}
|
|
360
|
+
for (const session of sessions) {
|
|
361
|
+
if (session.id === id) return session
|
|
362
|
+
}
|
|
363
|
+
return undefined
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const removeSession = (session) => {
|
|
367
|
+
sessions.delete(session)
|
|
368
|
+
const group = groups.get(session.root)
|
|
369
|
+
if (group !== undefined) {
|
|
370
|
+
group.delete(session.id)
|
|
371
|
+
if (group.size === 0) groups.delete(session.root)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
router.method('terminal.list', async (body) => {
|
|
376
|
+
if (disposed) return { terminals: [] }
|
|
377
|
+
const project = env.projects.ensure(body?.root)
|
|
378
|
+
const group = groups.get(project.root)
|
|
379
|
+
const list = group === undefined ? [] : [...group.values()].map((session) => session.toInfo())
|
|
380
|
+
debugLog('terminal-list', { root: project.root, count: list.length })
|
|
381
|
+
return { terminals: list }
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
router.method('terminal.create', async (body, context = {}) => {
|
|
385
|
+
if (disposed) throw new ApiError('terminal-unavailable', '终端能力已卸载', 503)
|
|
386
|
+
const project = env.projects.ensure(body?.root)
|
|
387
|
+
const subprocess = requireSubprocess(env)
|
|
388
|
+
const group = groupFor(project.root)
|
|
389
|
+
if (group.size >= MAX_TERMINALS_PER_ROOT) {
|
|
390
|
+
throw new ApiError('terminal-limit', `单个项目最多同时打开 ${MAX_TERMINALS_PER_ROOT} 个终端,请先关闭已有终端`, 409)
|
|
391
|
+
}
|
|
392
|
+
const cols = clampInteger(body?.cols, DEFAULT_COLS, MIN_COLS, MAX_COLS)
|
|
393
|
+
const rows = clampInteger(body?.rows, DEFAULT_ROWS, MIN_ROWS, MAX_ROWS)
|
|
394
|
+
const signal = context.signal
|
|
395
|
+
const preferred = await detectPreferredShell(subprocess, signal)
|
|
396
|
+
const shellPath = await resolveShellPath({
|
|
397
|
+
explicit: typeof body?.shell === 'string' ? body.shell : undefined,
|
|
398
|
+
preferred,
|
|
399
|
+
env: process.env,
|
|
400
|
+
})
|
|
401
|
+
let handle
|
|
402
|
+
try {
|
|
403
|
+
handle = await subprocess.spawnTerminal({
|
|
404
|
+
argv: [shellPath, ...terminalShellArgs()],
|
|
405
|
+
cwd: project.root,
|
|
406
|
+
env: (() => {
|
|
407
|
+
const env = filteredEnvironment(process.env)
|
|
408
|
+
// zsh 的 PROMPT_SP 会在首屏打印反显 % 的 PROMPT_EOL_MARK(提示上一行没换行);
|
|
409
|
+
// 集成终端里这是纯噪音,默认清空(用户自己设置过则尊重用户配置)。
|
|
410
|
+
if (env.PROMPT_EOL_MARK === undefined) env.PROMPT_EOL_MARK = ''
|
|
411
|
+
return env
|
|
412
|
+
})(),
|
|
413
|
+
rows,
|
|
414
|
+
cols,
|
|
415
|
+
terminalType: TERMINAL_TYPE,
|
|
416
|
+
graceMs: TERMINAL_GRACE_MS,
|
|
417
|
+
...(signal === undefined ? {} : { signal }),
|
|
418
|
+
})
|
|
419
|
+
} catch (error) {
|
|
420
|
+
debugLog('terminal-create-failed', { shellPath, message: error instanceof Error ? error.message : String(error) })
|
|
421
|
+
if (signal?.aborted === true) throw new ApiError('aborted', '终端创建已取消', 499)
|
|
422
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
423
|
+
throw new ApiError('terminal-spawn-failed', `无法启动终端(${shellPath}):${message.slice(0, 2000)}`, 500)
|
|
424
|
+
}
|
|
425
|
+
let session
|
|
426
|
+
try {
|
|
427
|
+
session = new TerminalSession({
|
|
428
|
+
id: `term-${randomUUID()}`,
|
|
429
|
+
root: project.root,
|
|
430
|
+
title: terminalTitleFor(shellPath),
|
|
431
|
+
shell: shellPath,
|
|
432
|
+
handle,
|
|
433
|
+
cols,
|
|
434
|
+
rows,
|
|
435
|
+
})
|
|
436
|
+
} catch (error) {
|
|
437
|
+
// 会话对象构造失败时不要让已分配的 PTY 泄漏
|
|
438
|
+
try {
|
|
439
|
+
void handle.terminate()
|
|
440
|
+
} catch {
|
|
441
|
+
/* ignore */
|
|
442
|
+
}
|
|
443
|
+
throw new ApiError('terminal-spawn-failed', `终端初始化失败:${error instanceof Error ? error.message : String(error)}`, 500)
|
|
444
|
+
}
|
|
445
|
+
group.set(session.id, session)
|
|
446
|
+
sessions.add(session)
|
|
447
|
+
debugLog('terminal-create', { id: session.id, shell: session.shell, pid: handle?.pid ?? null, rows, cols, root: project.root, total: sessions.size })
|
|
448
|
+
return { id: session.id, title: session.title }
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
router.method('terminal.close', async (body) => {
|
|
452
|
+
const project = (() => {
|
|
453
|
+
try {
|
|
454
|
+
return env.projects.ensure(body?.root)
|
|
455
|
+
} catch {
|
|
456
|
+
return undefined
|
|
457
|
+
}
|
|
458
|
+
})()
|
|
459
|
+
const snapshot = project === undefined ? undefined : groups.get(project.root)?.get(String(body?.id ?? ''))
|
|
460
|
+
const session = snapshot ?? findSession(body?.root, body?.id)
|
|
461
|
+
if (session === undefined) throw new ApiError('not-found', '终端不存在或已关闭', 404)
|
|
462
|
+
removeSession(session)
|
|
463
|
+
await session.dispose()
|
|
464
|
+
return { ok: true }
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
/* ── 自检探针:直接走 Live Host 的 subprocess.spawnTerminal,验证真实 PTY ── */
|
|
468
|
+
router.method('terminal.probe', async (body) => {
|
|
469
|
+
const result = { at: new Date().toISOString(), env: null, spawn: 'skipped', pid: null, firstOutput: '', afterWriteOutput: '', writeOk: false, exit: null, error: null }
|
|
470
|
+
try {
|
|
471
|
+
const subprocess = requireSubprocess(env)
|
|
472
|
+
try {
|
|
473
|
+
result.env = typeof subprocess.terminalEnvironment === 'function' ? await subprocess.terminalEnvironment() : null
|
|
474
|
+
} catch (error) {
|
|
475
|
+
result.env = { error: String(error) }
|
|
476
|
+
}
|
|
477
|
+
let cwd = process.cwd()
|
|
478
|
+
if (typeof body?.root === 'string' && body.root !== '') {
|
|
479
|
+
try {
|
|
480
|
+
cwd = env.projects.ensure(body.root).root
|
|
481
|
+
} catch {
|
|
482
|
+
/* 用 process.cwd() 兜底 */
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const shellPath = await resolveShellPath({ preferred: undefined, env: process.env }).catch(() => '/bin/zsh')
|
|
486
|
+
let handle
|
|
487
|
+
try {
|
|
488
|
+
handle = await subprocess.spawnTerminal({
|
|
489
|
+
argv: [shellPath, ...terminalShellArgs()],
|
|
490
|
+
cwd,
|
|
491
|
+
env: filteredEnvironment(process.env),
|
|
492
|
+
rows: 24,
|
|
493
|
+
cols: 80,
|
|
494
|
+
terminalType: TERMINAL_TYPE,
|
|
495
|
+
graceMs: TERMINAL_GRACE_MS,
|
|
496
|
+
})
|
|
497
|
+
result.spawn = 'ok'
|
|
498
|
+
result.pid = handle?.pid ?? null
|
|
499
|
+
} catch (error) {
|
|
500
|
+
result.spawn = 'failed'
|
|
501
|
+
result.error = String(error)
|
|
502
|
+
debugLog('terminal-probe', result)
|
|
503
|
+
return result
|
|
504
|
+
}
|
|
505
|
+
let output = ''
|
|
506
|
+
handle.output?.on?.('data', (chunk) => {
|
|
507
|
+
if (output.length < 4000) output += String(chunk).slice(0, 4000)
|
|
508
|
+
})
|
|
509
|
+
handle.output?.once?.('error', (error) => {
|
|
510
|
+
result.outputError = String(error)
|
|
511
|
+
})
|
|
512
|
+
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
513
|
+
result.firstOutput = output.slice(0, 2000)
|
|
514
|
+
try {
|
|
515
|
+
await handle.write('echo GUFA_PROBE_WRITE_OK\r')
|
|
516
|
+
result.writeOk = true
|
|
517
|
+
} catch (error) {
|
|
518
|
+
result.writeError = String(error)
|
|
519
|
+
}
|
|
520
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
521
|
+
result.afterWriteOutput = output.slice(0, 4000)
|
|
522
|
+
try {
|
|
523
|
+
await settleWithTimeout(handle.terminate(), 3000)
|
|
524
|
+
result.exit = 'terminated'
|
|
525
|
+
} catch (error) {
|
|
526
|
+
result.exitError = String(error)
|
|
527
|
+
}
|
|
528
|
+
} catch (error) {
|
|
529
|
+
result.error = String(error)
|
|
530
|
+
}
|
|
531
|
+
debugLog('terminal-probe', result)
|
|
532
|
+
return result
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
const findSessionByBody = (body) => {
|
|
536
|
+
const root = typeof body?.root === 'string' ? body.root : ''
|
|
537
|
+
const id = typeof body?.id === 'string' ? body.id : ''
|
|
538
|
+
return groups.get(root)?.get(id)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
router.method('terminal.input', async (body) => {
|
|
542
|
+
const session = findSessionByBody(body)
|
|
543
|
+
if (session === undefined) throw new ApiError('not-found', '终端不存在或已关闭', 404)
|
|
544
|
+
if (typeof body?.data === 'string' && body.data !== '') session.write(body.data)
|
|
545
|
+
return { ok: true }
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
router.method('terminal.resize', async (body) => {
|
|
549
|
+
const session = findSessionByBody(body)
|
|
550
|
+
if (session === undefined) throw new ApiError('not-found', '终端不存在或已关闭', 404)
|
|
551
|
+
const cols = clampInteger(body?.cols, session.cols, MIN_COLS, MAX_COLS)
|
|
552
|
+
const rows = clampInteger(body?.rows, session.rows, MIN_ROWS, MAX_ROWS)
|
|
553
|
+
session.resize(cols, rows)
|
|
554
|
+
return { ok: true }
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
/* SSE 传输:桌面壳(dsh-app://)里 WS 上行可用、下行帧不达,输出改走 HTTP 流。 */
|
|
558
|
+
router.stream('terminal.stream', async (body, { send, close, signal }) => {
|
|
559
|
+
if (disposed) {
|
|
560
|
+
send({ type: 'error', message: '终端能力已卸载' })
|
|
561
|
+
close()
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
const session = findSessionByBody(body)
|
|
565
|
+
if (session === undefined) {
|
|
566
|
+
send({ type: 'error', message: '终端不存在或已关闭' })
|
|
567
|
+
close()
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
const adapter = {
|
|
571
|
+
readyState: WS_OPEN,
|
|
572
|
+
bufferedAmount: 0,
|
|
573
|
+
send(raw) {
|
|
574
|
+
try {
|
|
575
|
+
send(JSON.parse(String(raw)))
|
|
576
|
+
} catch {
|
|
577
|
+
/* ignore */
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
close() {
|
|
581
|
+
close()
|
|
582
|
+
},
|
|
583
|
+
ping() {},
|
|
584
|
+
}
|
|
585
|
+
const sinceRaw = body?.since
|
|
586
|
+
const since = typeof sinceRaw === 'number' && Number.isSafeInteger(sinceRaw) && sinceRaw >= 0 ? sinceRaw : null
|
|
587
|
+
debugLog('sse-attach', { id: session.id, since })
|
|
588
|
+
send({ type: 'ready', id: session.id })
|
|
589
|
+
session.attach(adapter, since)
|
|
590
|
+
await new Promise((resolve) => {
|
|
591
|
+
if (signal !== undefined && signal.aborted === true) {
|
|
592
|
+
resolve()
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
if (signal !== undefined) signal.addEventListener('abort', () => resolve(), { once: true })
|
|
596
|
+
})
|
|
597
|
+
session.detach(adapter)
|
|
598
|
+
debugLog('sse-detach', { id: session.id })
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
const handleSocket = (connection, url) => {
|
|
602
|
+
if (disposed) {
|
|
603
|
+
wsJson(connection, { type: 'error', message: '终端能力已卸载' })
|
|
604
|
+
try {
|
|
605
|
+
connection.close(1011, 'terminal disposed')
|
|
606
|
+
} catch {
|
|
607
|
+
/* ignore */
|
|
608
|
+
}
|
|
609
|
+
return undefined
|
|
610
|
+
}
|
|
611
|
+
const root = url.searchParams.get('root') ?? ''
|
|
612
|
+
const id = url.searchParams.get('id') ?? ''
|
|
613
|
+
const session = groups.get(root)?.get(id)
|
|
614
|
+
debugLog('ws-socket', { id, root, found: session !== undefined, sockets: sessions.size })
|
|
615
|
+
if (session === undefined) {
|
|
616
|
+
wsJson(connection, { type: 'error', message: '终端不存在或已关闭' })
|
|
617
|
+
try {
|
|
618
|
+
connection.close(1011, 'terminal not found')
|
|
619
|
+
} catch {
|
|
620
|
+
/* ignore */
|
|
621
|
+
}
|
|
622
|
+
return undefined
|
|
623
|
+
}
|
|
624
|
+
const sinceRaw = url.searchParams.get('since')
|
|
625
|
+
const parsedSince = sinceRaw === null ? Number.NaN : Number.parseInt(sinceRaw, 10)
|
|
626
|
+
try {
|
|
627
|
+
wsJson(connection, { type: 'hello', id: session.id })
|
|
628
|
+
debugLog('ws-hello', { id: session.id, readyState: connection.readyState })
|
|
629
|
+
} catch (error) {
|
|
630
|
+
debugLog('ws-hello-error', String(error))
|
|
631
|
+
}
|
|
632
|
+
session.attach(connection, Number.isSafeInteger(parsedSince) && parsedSince >= 0 ? parsedSince : null)
|
|
633
|
+
const hello2Timer = setTimeout(() => {
|
|
634
|
+
try {
|
|
635
|
+
wsJson(connection, { type: 'hello2' })
|
|
636
|
+
debugLog('ws-hello2', { id: session.id, readyState: connection.readyState, bufferedAmount: connection.bufferedAmount })
|
|
637
|
+
} catch (error) {
|
|
638
|
+
debugLog('ws-hello2-error', String(error))
|
|
639
|
+
}
|
|
640
|
+
}, 800)
|
|
641
|
+
|
|
642
|
+
const heartbeat = setInterval(() => {
|
|
643
|
+
try {
|
|
644
|
+
if (connection.readyState === WS_OPEN) connection.ping()
|
|
645
|
+
} catch {
|
|
646
|
+
/* 连接可能正在关闭 */
|
|
647
|
+
}
|
|
648
|
+
}, HEARTBEAT_MS)
|
|
649
|
+
heartbeat.unref?.()
|
|
650
|
+
|
|
651
|
+
let detached = false
|
|
652
|
+
const detach = () => {
|
|
653
|
+
if (detached) return
|
|
654
|
+
detached = true
|
|
655
|
+
clearInterval(heartbeat)
|
|
656
|
+
clearTimeout(hello2Timer)
|
|
657
|
+
session.detach(connection)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
let receivedMessages = 0
|
|
661
|
+
const onMessage = (raw) => {
|
|
662
|
+
receivedMessages += 1
|
|
663
|
+
if (receivedMessages <= 3) debugLog('ws-message', { id: session.id, n: receivedMessages, raw: String(raw).slice(0, 160) })
|
|
664
|
+
const message = parseWsJson(raw)
|
|
665
|
+
if (message === undefined) return
|
|
666
|
+
if (message.type === 'input') {
|
|
667
|
+
if (typeof message.data !== 'string' || message.data === '') return
|
|
668
|
+
if (Buffer.byteLength(message.data, 'utf8') > MAX_INPUT_BYTES) return
|
|
669
|
+
session.write(message.data)
|
|
670
|
+
} else if (message.type === 'resize') {
|
|
671
|
+
const cols = clampInteger(message.cols, session.cols, MIN_COLS, MAX_COLS)
|
|
672
|
+
const rows = clampInteger(message.rows, session.rows, MIN_ROWS, MAX_ROWS)
|
|
673
|
+
if (cols !== session.cols || rows !== session.rows) session.resize(cols, rows)
|
|
674
|
+
} else if (message.type === 'ping') {
|
|
675
|
+
debugLog('ws-ping', { id: session.id })
|
|
676
|
+
wsJson(connection, { type: 'pong' })
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const onClose = () => detach()
|
|
680
|
+
connection.on('message', onMessage)
|
|
681
|
+
connection.once('close', onClose)
|
|
682
|
+
connection.on('error', () => {
|
|
683
|
+
/* 关闭事件随后到达 */
|
|
684
|
+
})
|
|
685
|
+
return () => detach()
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
router.ws('terminal', handleSocket)
|
|
689
|
+
|
|
690
|
+
/* ── 桌面壳(dsh-app://)专用的终端 WS 通道 ─────────────────────────────
|
|
691
|
+
* 桌面壳的页面源是 dsh-app://app,直接连 ws://127.0.0.1:PORT 时 Host/Origin 围栏
|
|
692
|
+
* 会拒绝(Origin 不是 http 同源)。这里注册一条独立升级路由,用「已鉴权 HTTP API
|
|
693
|
+
* 签发的短期票据」认证;没有票据时仍回退到 Harness 的标准鉴权。 */
|
|
694
|
+
const tickets = new Map()
|
|
695
|
+
const issueTicket = () => {
|
|
696
|
+
const now = Date.now()
|
|
697
|
+
for (const [key, expiry] of tickets) if (expiry <= now) tickets.delete(key)
|
|
698
|
+
const ticket = randomUUID()
|
|
699
|
+
tickets.set(ticket, now + WS_TICKET_TTL_MS)
|
|
700
|
+
return ticket
|
|
701
|
+
}
|
|
702
|
+
const ticketValid = (ticket) => {
|
|
703
|
+
if (typeof ticket !== 'string' || ticket === '') return false
|
|
704
|
+
const expiry = tickets.get(ticket)
|
|
705
|
+
return expiry !== undefined && expiry > Date.now()
|
|
706
|
+
}
|
|
707
|
+
router.method('terminal.wsTicket', async () => ({ ticket: issueTicket() }))
|
|
708
|
+
|
|
709
|
+
const webServer = env.ctx === undefined || env.ctx === null ? undefined : env.ctx.webServer
|
|
710
|
+
|
|
711
|
+
/* 直连真实 Host 的 SSE 通道(桌面壳 dsh-app:// 页面用;票据鉴权 + CORS)。 */
|
|
712
|
+
if (webServer !== undefined && webServer !== null && typeof webServer.register === 'function') {
|
|
713
|
+
let disposeSseRoute = null
|
|
714
|
+
let sseRetryTimer = null
|
|
715
|
+
const sseHandler = async (req, res) => {
|
|
716
|
+
try {
|
|
717
|
+
res.setHeader('access-control-allow-origin', '*')
|
|
718
|
+
res.setHeader('access-control-allow-methods', 'GET, OPTIONS')
|
|
719
|
+
if (req.method === 'OPTIONS') {
|
|
720
|
+
res.writeHead(204)
|
|
721
|
+
res.end()
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
if (req.method !== 'GET') {
|
|
725
|
+
res.writeHead(405)
|
|
726
|
+
res.end()
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
let url
|
|
730
|
+
try {
|
|
731
|
+
url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
732
|
+
} catch {
|
|
733
|
+
res.writeHead(400)
|
|
734
|
+
res.end()
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
if (!ticketValid(url.searchParams.get('ticket') ?? '')) {
|
|
738
|
+
debugLog('sse2-reject', { reason: 'ticket', origin: req.headers.origin, host: req.headers.host })
|
|
739
|
+
res.writeHead(401, { 'content-type': 'application/json', 'access-control-allow-origin': '*' })
|
|
740
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'unauthorized', message: 'bad ticket' } }))
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
res.writeHead(200, {
|
|
744
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
745
|
+
'cache-control': 'no-store',
|
|
746
|
+
'x-accel-buffering': 'no',
|
|
747
|
+
'access-control-allow-origin': '*',
|
|
748
|
+
})
|
|
749
|
+
const writeEvent = (payload) => {
|
|
750
|
+
if (res.writableEnded) return
|
|
751
|
+
try {
|
|
752
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
|
753
|
+
} catch {
|
|
754
|
+
/* 连接可能已断 */
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
res.write(': connected\n\n')
|
|
758
|
+
if (disposed) {
|
|
759
|
+
writeEvent({ type: 'error', message: '终端能力已卸载' })
|
|
760
|
+
res.end()
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
const root = url.searchParams.get('root') ?? ''
|
|
764
|
+
const id = url.searchParams.get('id') ?? ''
|
|
765
|
+
const session = groups.get(root)?.get(id)
|
|
766
|
+
if (session === undefined) {
|
|
767
|
+
writeEvent({ type: 'error', message: '终端不存在或已关闭' })
|
|
768
|
+
res.end()
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
const adapter = {
|
|
772
|
+
readyState: WS_OPEN,
|
|
773
|
+
bufferedAmount: 0,
|
|
774
|
+
send(raw) {
|
|
775
|
+
try {
|
|
776
|
+
writeEvent(JSON.parse(String(raw)))
|
|
777
|
+
} catch {
|
|
778
|
+
/* ignore */
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
close() {
|
|
782
|
+
try {
|
|
783
|
+
res.end()
|
|
784
|
+
} catch {
|
|
785
|
+
/* ignore */
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
ping() {},
|
|
789
|
+
}
|
|
790
|
+
writeEvent({ type: 'ready', id: session.id })
|
|
791
|
+
const sinceRaw = url.searchParams.get('since')
|
|
792
|
+
const parsedSince = sinceRaw === null ? Number.NaN : Number.parseInt(sinceRaw, 10)
|
|
793
|
+
debugLog('sse2-attach', { id: session.id, origin: req.headers.origin })
|
|
794
|
+
session.attach(adapter, Number.isSafeInteger(parsedSince) && parsedSince >= 0 ? parsedSince : null)
|
|
795
|
+
const heartbeat = setInterval(() => {
|
|
796
|
+
if (!res.writableEnded) {
|
|
797
|
+
try {
|
|
798
|
+
res.write(': ping\n\n')
|
|
799
|
+
} catch {
|
|
800
|
+
/* ignore */
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}, 15000)
|
|
804
|
+
heartbeat.unref?.()
|
|
805
|
+
await new Promise((resolve) => {
|
|
806
|
+
res.once('close', resolve)
|
|
807
|
+
res.once('error', resolve)
|
|
808
|
+
})
|
|
809
|
+
clearInterval(heartbeat)
|
|
810
|
+
session.detach(adapter)
|
|
811
|
+
debugLog('sse2-detach', { id: session.id })
|
|
812
|
+
} catch (error) {
|
|
813
|
+
debugLog('sse2-error', String(error))
|
|
814
|
+
try {
|
|
815
|
+
if (!res.writableEnded) res.end()
|
|
816
|
+
} catch {
|
|
817
|
+
/* ignore */
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const installSse = (attempt = 0) => {
|
|
822
|
+
if (disposed) return
|
|
823
|
+
try {
|
|
824
|
+
disposeSseRoute = webServer.register({ kind: 'exact', path: '/gufa-code/terminal-stream', handler: sseHandler })
|
|
825
|
+
debugLog('sse2-route-installed')
|
|
826
|
+
} catch (error) {
|
|
827
|
+
if (attempt < 30) {
|
|
828
|
+
sseRetryTimer = setTimeout(() => installSse(attempt + 1), 50)
|
|
829
|
+
sseRetryTimer.unref?.()
|
|
830
|
+
} else {
|
|
831
|
+
debugLog('sse2-route-failed', String(error))
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
installSse()
|
|
836
|
+
router.onDispose(() => {
|
|
837
|
+
if (sseRetryTimer !== null) clearTimeout(sseRetryTimer)
|
|
838
|
+
if (typeof disposeSseRoute === 'function') {
|
|
839
|
+
try {
|
|
840
|
+
disposeSseRoute()
|
|
841
|
+
} catch {
|
|
842
|
+
/* ignore */
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (webServer !== undefined && webServer !== null && typeof webServer.registerUpgrade === 'function') {
|
|
849
|
+
let wss = null
|
|
850
|
+
let disposeRoute = null
|
|
851
|
+
let retryTimer = null
|
|
852
|
+
const installUpgrade = (attempt = 0) => {
|
|
853
|
+
if (disposed) return
|
|
854
|
+
try {
|
|
855
|
+
wss = new WebSocketServer({ noServer: true, perMessageDeflate: false })
|
|
856
|
+
disposeRoute = webServer.registerUpgrade({
|
|
857
|
+
path: '/gufa-code/ws-terminal',
|
|
858
|
+
handler: (req, socket, head) => {
|
|
859
|
+
if (disposed) {
|
|
860
|
+
socket.destroy()
|
|
861
|
+
return
|
|
862
|
+
}
|
|
863
|
+
let url
|
|
864
|
+
try {
|
|
865
|
+
url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
866
|
+
} catch {
|
|
867
|
+
socket.destroy()
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
const ticket = url.searchParams.get('ticket') ?? ''
|
|
871
|
+
let authorized = ticketValid(ticket)
|
|
872
|
+
if (!authorized) {
|
|
873
|
+
try {
|
|
874
|
+
authorized = env.ctx.connection.requestRejection(req) === undefined
|
|
875
|
+
} catch {
|
|
876
|
+
authorized = false
|
|
877
|
+
}
|
|
878
|
+
debugLog('terminal-ws', 'ticket-miss', 'cookie-auth=', authorized, 'host=', req.headers.host, 'origin=', req.headers.origin, 'sfs=', req.headers['sec-fetch-site'])
|
|
879
|
+
}
|
|
880
|
+
if (!authorized) {
|
|
881
|
+
socket.destroy()
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
try {
|
|
885
|
+
wss.handleUpgrade(req, socket, head, (connection) => {
|
|
886
|
+
try {
|
|
887
|
+
handleSocket(connection, url)
|
|
888
|
+
} catch (error) {
|
|
889
|
+
debugLog('terminal-ws', 'handler-error', String(error))
|
|
890
|
+
try {
|
|
891
|
+
connection.close(1011, 'terminal failed')
|
|
892
|
+
} catch {
|
|
893
|
+
/* ignore */
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
})
|
|
897
|
+
} catch (error) {
|
|
898
|
+
debugLog('terminal-ws', 'upgrade-error', String(error))
|
|
899
|
+
socket.destroy()
|
|
900
|
+
}
|
|
901
|
+
},
|
|
902
|
+
})
|
|
903
|
+
debugLog('terminal-ws', 'route-installed')
|
|
904
|
+
} catch (error) {
|
|
905
|
+
// 上一代作用域的升级路由可能还没释放:稍后重试(最多 ~1.5s)
|
|
906
|
+
if (attempt < 30) {
|
|
907
|
+
retryTimer = setTimeout(() => installUpgrade(attempt + 1), 50)
|
|
908
|
+
retryTimer.unref?.()
|
|
909
|
+
} else {
|
|
910
|
+
debugLog('terminal-ws', 'route-install-failed', String(error))
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
installUpgrade()
|
|
915
|
+
router.onDispose(() => {
|
|
916
|
+
disposed = true
|
|
917
|
+
if (retryTimer !== null) clearTimeout(retryTimer)
|
|
918
|
+
if (typeof disposeRoute === 'function') {
|
|
919
|
+
try {
|
|
920
|
+
disposeRoute()
|
|
921
|
+
} catch {
|
|
922
|
+
/* ignore */
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (wss !== null) {
|
|
926
|
+
try {
|
|
927
|
+
wss.close()
|
|
928
|
+
} catch {
|
|
929
|
+
/* ignore */
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
})
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
router.onDispose(() => {
|
|
936
|
+
disposed = true
|
|
937
|
+
for (const session of [...sessions]) {
|
|
938
|
+
try {
|
|
939
|
+
void session.dispose()
|
|
940
|
+
} catch {
|
|
941
|
+
/* ignore */
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
sessions.clear()
|
|
945
|
+
groups.clear()
|
|
946
|
+
})
|
|
947
|
+
}
|