dsh-gufa-code 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -2
  3. package/client.js +11548 -0
  4. package/cordis.patch.yml +8 -0
  5. package/docs/icon-180.png +0 -0
  6. package/docs/icon.svg +18 -0
  7. package/docs/market-submission.md +32 -0
  8. package/docs/screenshot-editor.jpg +0 -0
  9. package/docs/screenshot-ide.jpg +0 -0
  10. package/host/assets.js +31 -0
  11. package/host/chat.js +523 -0
  12. package/host/context.js +438 -0
  13. package/host/dev.js +35 -0
  14. package/host/fs.js +306 -0
  15. package/host/git.js +532 -0
  16. package/host/project.js +305 -0
  17. package/host/ssh.js +54 -0
  18. package/host/terminal-pty.js +237 -0
  19. package/host/terminal.js +947 -0
  20. package/icon.svg +18 -0
  21. package/index.js +63 -0
  22. package/locale/en.json +121 -0
  23. package/locale/zh.json +121 -0
  24. package/package.json +66 -4
  25. package/scripts/build-client.mjs +38 -0
  26. package/scripts/selftest-client.mjs +457 -0
  27. package/scripts/selftest-host.mjs +197 -0
  28. package/scripts/selftest-http.mjs +284 -0
  29. package/scripts/selftest-router.mjs +101 -0
  30. package/scripts/selftest-terminal-client.mjs +314 -0
  31. package/scripts/selftest-terminal-pty.mjs +190 -0
  32. package/scripts/selftest-terminal.mjs +351 -0
  33. package/scripts/test-chat-render.mjs +702 -0
  34. package/scripts/test-chat.mjs +761 -0
  35. package/scripts/test-editor.mjs +516 -0
  36. package/scripts/verify-workspace-sessions.mjs +135 -0
  37. package/src/client/00-core.js +1686 -0
  38. package/src/client/10-icons.js +205 -0
  39. package/src/client/20-editor.js +1318 -0
  40. package/src/client/30-terminal.js +1510 -0
  41. package/src/client/40-chat.js +1184 -0
  42. package/src/client/46-gitgraph.js +278 -0
  43. package/src/client/50-panels.js +916 -0
  44. package/src/client/55-backdrop.js +508 -0
  45. package/src/client/60-dock.js +662 -0
  46. package/src/client/85-shortcuts.js +516 -0
  47. package/src/client/90-app.js +2272 -0
  48. package/src/client/99-main.js +449 -0
  49. package/vendor/monaco/LICENSE +21 -0
  50. package/vendor/monaco/ThirdPartyNotices.txt +448 -0
  51. package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. package/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. package/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. package/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. package/vendor/monaco/vs/loader.js +11 -0
  145. package/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. package/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. package/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. package/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. package/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. package/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. package/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. package/vendor/xterm/LICENSE +21 -0
  155. package/vendor/xterm/LICENSE.addon-fit +19 -0
  156. package/vendor/xterm/addon-fit.js +2 -0
  157. package/vendor/xterm/xterm.css +285 -0
  158. package/vendor/xterm/xterm.js +2 -0
@@ -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
+ }