@lijian-ui/dsh-term 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +7824 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +434 -0
- package/lib/tsconfig.client.tsbuildinfo +1 -0
- package/lib/tsconfig.host.tsbuildinfo +1 -0
- package/lib/types/client/index.d.ts +26 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/term/TerminalPanel.d.ts +24 -0
- package/lib/types/client/term/TerminalPanel.d.ts.map +1 -0
- package/lib/types/client/term/api.d.ts +34 -0
- package/lib/types/client/term/api.d.ts.map +1 -0
- package/lib/types/client/term/xterm-styles.d.ts +9 -0
- package/lib/types/client/term/xterm-styles.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +76 -0
- package/lib/types/core/types.d.ts.map +1 -0
- package/lib/types/host/loopback.d.ts +25 -0
- package/lib/types/host/loopback.d.ts.map +1 -0
- package/lib/types/host/pty-service.d.ts +45 -0
- package/lib/types/host/pty-service.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +17 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/package.json +79 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +223 -0
- package/src/client/term/TerminalPanel.tsx +259 -0
- package/src/client/term/api.ts +66 -0
- package/src/client/term/term.module.css +153 -0
- package/src/client/term/xterm-styles.ts +182 -0
- package/src/core/types.ts +73 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/pty-service.ts +131 -0
- package/src/host/routes.ts +198 -0
- package/src/index.ts +37 -0
- package/src/mount-once.ts +48 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inlined xterm.js 5.3.0 core styles (MIT) — injected as a <style> tag at
|
|
3
|
+
* runtime because the client build compiles only *.module.css and the
|
|
4
|
+
* shell's css-modules pipeline rejects a raw :global block with xterm's
|
|
5
|
+
* class names.
|
|
6
|
+
* @module dsh-term/client/term/xterm-styles
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const XTERM_CSS: string = `
|
|
10
|
+
.xterm {
|
|
11
|
+
cursor: text;
|
|
12
|
+
position: relative;
|
|
13
|
+
user-select: none;
|
|
14
|
+
-ms-user-select: none;
|
|
15
|
+
-webkit-user-select: none;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.xterm.focus,
|
|
19
|
+
.xterm:focus {
|
|
20
|
+
outline: none;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.xterm .xterm-helpers {
|
|
24
|
+
position: absolute;
|
|
25
|
+
top: 0;
|
|
26
|
+
/**
|
|
27
|
+
* The z-index of the helpers must be higher than the canvases in order for
|
|
28
|
+
* IMEs to appear on top.
|
|
29
|
+
*/
|
|
30
|
+
z-index: 5;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.xterm .xterm-helper-textarea {
|
|
34
|
+
padding: 0;
|
|
35
|
+
border: 0;
|
|
36
|
+
margin: 0;
|
|
37
|
+
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
|
38
|
+
position: absolute;
|
|
39
|
+
opacity: 0;
|
|
40
|
+
left: -9999em;
|
|
41
|
+
top: 0;
|
|
42
|
+
width: 0;
|
|
43
|
+
height: 0;
|
|
44
|
+
z-index: -5;
|
|
45
|
+
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
resize: none;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.xterm .composition-view {
|
|
52
|
+
/* TODO: Composition position got messed up somewhere */
|
|
53
|
+
background: #000;
|
|
54
|
+
color: #FFF;
|
|
55
|
+
display: none;
|
|
56
|
+
position: absolute;
|
|
57
|
+
white-space: nowrap;
|
|
58
|
+
z-index: 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.xterm .composition-view.active {
|
|
62
|
+
display: block;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.xterm .xterm-viewport {
|
|
66
|
+
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
|
67
|
+
background-color: #000;
|
|
68
|
+
overflow-y: scroll;
|
|
69
|
+
cursor: default;
|
|
70
|
+
position: absolute;
|
|
71
|
+
right: 0;
|
|
72
|
+
left: 0;
|
|
73
|
+
top: 0;
|
|
74
|
+
bottom: 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.xterm .xterm-screen {
|
|
78
|
+
position: relative;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.xterm .xterm-screen canvas {
|
|
82
|
+
position: absolute;
|
|
83
|
+
left: 0;
|
|
84
|
+
top: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.xterm .xterm-scroll-area {
|
|
88
|
+
visibility: hidden;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.xterm-char-measure-element {
|
|
92
|
+
display: inline-block;
|
|
93
|
+
visibility: hidden;
|
|
94
|
+
position: absolute;
|
|
95
|
+
top: 0;
|
|
96
|
+
left: -9999em;
|
|
97
|
+
line-height: normal;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.xterm.enable-mouse-events {
|
|
101
|
+
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
|
102
|
+
cursor: default;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.xterm.xterm-cursor-pointer,
|
|
106
|
+
.xterm .xterm-cursor-pointer {
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.xterm.column-select.focus {
|
|
111
|
+
/* Column selection mode */
|
|
112
|
+
cursor: crosshair;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.xterm .xterm-accessibility,
|
|
116
|
+
.xterm .xterm-message {
|
|
117
|
+
position: absolute;
|
|
118
|
+
left: 0;
|
|
119
|
+
top: 0;
|
|
120
|
+
bottom: 0;
|
|
121
|
+
right: 0;
|
|
122
|
+
z-index: 10;
|
|
123
|
+
color: transparent;
|
|
124
|
+
pointer-events: none;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.xterm .live-region {
|
|
128
|
+
position: absolute;
|
|
129
|
+
left: -9999px;
|
|
130
|
+
width: 1px;
|
|
131
|
+
height: 1px;
|
|
132
|
+
overflow: hidden;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.xterm-dim {
|
|
136
|
+
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
|
137
|
+
* explicitly in the generated class and reset to 1 here */
|
|
138
|
+
opacity: 1 !important;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.xterm-underline-1 { text-decoration: underline; }
|
|
142
|
+
.xterm-underline-2 { text-decoration: double underline; }
|
|
143
|
+
.xterm-underline-3 { text-decoration: wavy underline; }
|
|
144
|
+
.xterm-underline-4 { text-decoration: dotted underline; }
|
|
145
|
+
.xterm-underline-5 { text-decoration: dashed underline; }
|
|
146
|
+
|
|
147
|
+
.xterm-overline {
|
|
148
|
+
text-decoration: overline;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
|
152
|
+
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
|
153
|
+
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
|
154
|
+
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
|
155
|
+
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
|
156
|
+
|
|
157
|
+
.xterm-strikethrough {
|
|
158
|
+
text-decoration: line-through;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
|
162
|
+
z-index: 6;
|
|
163
|
+
position: absolute;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
|
167
|
+
z-index: 7;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.xterm-decoration-overview-ruler {
|
|
171
|
+
z-index: 8;
|
|
172
|
+
position: absolute;
|
|
173
|
+
top: 0;
|
|
174
|
+
right: 0;
|
|
175
|
+
pointer-events: none;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.xterm-decoration-top {
|
|
179
|
+
z-index: 2;
|
|
180
|
+
position: relative;
|
|
181
|
+
}
|
|
182
|
+
`
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types shared between the host half and the browser half of dsh-term.
|
|
3
|
+
* Pure data — no runtime imports (keeps the client bundle purity gate happy).
|
|
4
|
+
* @module dsh-term/core/types
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One live PTY session as the browser knows it. */
|
|
8
|
+
export interface TermSessionInfo {
|
|
9
|
+
/** Stable session id (the wire handle). */
|
|
10
|
+
readonly id: string
|
|
11
|
+
/** User-facing tab title (defaults to the shell name). */
|
|
12
|
+
readonly title: string
|
|
13
|
+
/** Session cwd (absolute). */
|
|
14
|
+
readonly cwd: string
|
|
15
|
+
/** PTY size at last resize. */
|
|
16
|
+
cols: number
|
|
17
|
+
rows: number
|
|
18
|
+
/** Whether the shell process is still alive. */
|
|
19
|
+
readonly alive: boolean
|
|
20
|
+
/** POSIX exit code when the session ended (null while alive). */
|
|
21
|
+
readonly exitCode: number | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Request: open a new PTY session. */
|
|
25
|
+
export interface TermSpawnRequest {
|
|
26
|
+
/** Optional session display name. */
|
|
27
|
+
name?: string
|
|
28
|
+
/** Working directory (defaults to the session's workspace cwd). */
|
|
29
|
+
cwd?: string
|
|
30
|
+
/** Shell executable (defaults to the platform shell). */
|
|
31
|
+
shell?: string
|
|
32
|
+
/** Shell arguments (defaults to an interactive login-less profile). */
|
|
33
|
+
args?: string[]
|
|
34
|
+
/** Initial terminal size. */
|
|
35
|
+
cols?: number
|
|
36
|
+
rows?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Request: deliver terminal input. */
|
|
40
|
+
export interface TermWriteRequest {
|
|
41
|
+
readonly id: string
|
|
42
|
+
/** Raw bytes to write into the PTY (UTF-8 string; xterm emits UTF-8). */
|
|
43
|
+
readonly data: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Request: resize one session. */
|
|
47
|
+
export interface TermResizeRequest {
|
|
48
|
+
readonly id: string
|
|
49
|
+
readonly cols: number
|
|
50
|
+
readonly rows: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Request: signal one session. */
|
|
54
|
+
export interface TermSignalRequest {
|
|
55
|
+
readonly id: string
|
|
56
|
+
/** PTY signal name (e.g. `SIGHUP`, `SIGINT`, `SIGTERM`, `SIGKILL`). */
|
|
57
|
+
readonly signal: 'SIGHUP' | 'SIGINT' | 'SIGTERM' | 'SIGKILL'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The full session listing snapshot. */
|
|
61
|
+
export interface TermListResponse {
|
|
62
|
+
readonly sessions: readonly TermSessionInfo[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One output chunk pushed over the SSE change stream. */
|
|
66
|
+
export type TermEvent =
|
|
67
|
+
| { readonly kind: 'output'; readonly id: string; readonly data: string }
|
|
68
|
+
| { readonly kind: 'exit'; readonly id: string; readonly exitCode: number }
|
|
69
|
+
| { readonly kind: 'start'; readonly session: TermSessionInfo }
|
|
70
|
+
| { readonly kind: 'closed'; readonly id: string }
|
|
71
|
+
|
|
72
|
+
/** The workspace-gated session id (host-minted; the wire carries only this). */
|
|
73
|
+
export type TermSessionId = string
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
4
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
5
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
6
|
+
* re-run the sync instead of editing a copy.
|
|
7
|
+
*
|
|
8
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
+
* @module dsh-web-ui-shared/host/loopback
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage } from 'node:http'
|
|
15
|
+
|
|
16
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
17
|
+
export function isIPv4Loopback(v4: string): boolean {
|
|
18
|
+
const parts = v4.split('.')
|
|
19
|
+
return parts.length === 4
|
|
20
|
+
&& parts[0] === '127'
|
|
21
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
25
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
26
|
+
if (address === undefined) return false
|
|
27
|
+
const normalized = address.toLowerCase()
|
|
28
|
+
if (normalized === '::1') return true
|
|
29
|
+
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
30
|
+
return isIPv4Loopback(normalized)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
34
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
35
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
36
|
+
return isIPv4Loopback(hostname)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
41
|
+
* header, plus browser same-origin markers. The socket address is
|
|
42
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
43
|
+
*/
|
|
44
|
+
export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
45
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
46
|
+
const host = request.headers.host
|
|
47
|
+
if (typeof host !== 'string') return false
|
|
48
|
+
let hostUrl: URL
|
|
49
|
+
try {
|
|
50
|
+
hostUrl = new URL('http://' + host)
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
55
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
56
|
+
const origin = request.headers.origin
|
|
57
|
+
if (origin === undefined) return true
|
|
58
|
+
try {
|
|
59
|
+
return new URL(origin).host === hostUrl.host
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY session service for dsh-term: a framework-free registry over node-pty.
|
|
3
|
+
*
|
|
4
|
+
* A "pure user terminal" (A-version) needs no dsh agent ownership — every
|
|
5
|
+
* session is a plain node-pty child process keyed by a host-minted id, with
|
|
6
|
+
* byte streams bridged to the browser over the /dsh-term/* HTTP layer. This
|
|
7
|
+
* deliberately does NOT use the official @deepseek-ai/dsh-terminal service:
|
|
8
|
+
* that registry requires an exact `Agent` owner (model-facing semantics) and
|
|
9
|
+
* its resolution path from a user-initiated web route is unverified; node-pty
|
|
10
|
+
* is already present in the desktop tree (ABI-matched, verified loadable) and
|
|
11
|
+
* gives full control over multi-tab local shells.
|
|
12
|
+
* @module dsh-term/host/pty-service
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import * as nodePty from 'node-pty'
|
|
17
|
+
import type { TermSessionInfo, TermSpawnRequest } from '../core/types.ts'
|
|
18
|
+
|
|
19
|
+
/** Live session plus its pty handle (service-private). */
|
|
20
|
+
interface LiveSession {
|
|
21
|
+
readonly info: TermSessionInfo
|
|
22
|
+
readonly pty: nodePty.IPty
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Default interactive shell for the platform. */
|
|
26
|
+
export function defaultShell(): string {
|
|
27
|
+
if (process.platform === 'win32') return 'powershell.exe'
|
|
28
|
+
return process.env.SHELL ?? '/bin/bash'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Default args for an interactive login-less shell. */
|
|
32
|
+
export function defaultArgs(shell: string): string[] {
|
|
33
|
+
if (process.platform === 'win32') return []
|
|
34
|
+
const base = shell.endsWith('bash') ? ['--noprofile', '--norc', '-i'] : ['-i']
|
|
35
|
+
return base
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The PTY registry. Every mutation goes through this class so the route
|
|
40
|
+
* layer stays a thin HTTP shape (the file-manager pattern). Output/exit
|
|
41
|
+
* callbacks are assignable so the SSE layer can bind them after construction.
|
|
42
|
+
*/
|
|
43
|
+
export class PtyService {
|
|
44
|
+
private readonly sessions = new Map<string, LiveSession>()
|
|
45
|
+
|
|
46
|
+
/** Fired with raw PTY output chunks (UTF-8). Bound by the route layer. */
|
|
47
|
+
onOutput: (sessionId: string, data: string) => void = () => {}
|
|
48
|
+
/** Fired once when a session exits. Bound by the route layer. */
|
|
49
|
+
onExit: (sessionId: string, exitCode: number) => void = () => {}
|
|
50
|
+
|
|
51
|
+
/** Open one session; returns the wire info immediately (output streams async). */
|
|
52
|
+
spawn(req: TermSpawnRequest): TermSessionInfo {
|
|
53
|
+
const id = randomUUID()
|
|
54
|
+
const shell = req.shell ?? defaultShell()
|
|
55
|
+
const args = req.args ?? defaultArgs(shell)
|
|
56
|
+
const cols = req.cols ?? 80
|
|
57
|
+
const rows = req.rows ?? 24
|
|
58
|
+
const cwd = req.cwd ?? process.cwd()
|
|
59
|
+
const pty = nodePty.spawn(shell, args, {
|
|
60
|
+
name: 'xterm-256color',
|
|
61
|
+
cols,
|
|
62
|
+
rows,
|
|
63
|
+
cwd,
|
|
64
|
+
})
|
|
65
|
+
const info: TermSessionInfo = {
|
|
66
|
+
id,
|
|
67
|
+
title: req.name ?? shell,
|
|
68
|
+
cwd,
|
|
69
|
+
cols,
|
|
70
|
+
rows,
|
|
71
|
+
alive: true,
|
|
72
|
+
exitCode: null,
|
|
73
|
+
}
|
|
74
|
+
this.sessions.set(id, { info, pty })
|
|
75
|
+
pty.onData((data) => { this.onOutput(id, data) })
|
|
76
|
+
pty.onExit(({ exitCode }) => {
|
|
77
|
+
this.sessions.delete(id)
|
|
78
|
+
this.onExit(id, exitCode)
|
|
79
|
+
})
|
|
80
|
+
return info
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Write raw bytes (UTF-8) into a session. Returns false when unknown. */
|
|
84
|
+
write(id: string, data: string): boolean {
|
|
85
|
+
const live = this.sessions.get(id)
|
|
86
|
+
if (live === undefined) return false
|
|
87
|
+
try { live.pty.write(data) } catch { return false }
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Resize a session. Returns false when unknown. */
|
|
92
|
+
resize(id: string, cols: number, rows: number): boolean {
|
|
93
|
+
const live = this.sessions.get(id)
|
|
94
|
+
if (live === undefined) return false
|
|
95
|
+
try {
|
|
96
|
+
live.pty.resize(Math.max(2, cols), Math.max(2, rows))
|
|
97
|
+
live.info.cols = cols
|
|
98
|
+
live.info.rows = rows
|
|
99
|
+
} catch { return false }
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Deliver a signal (SIGINT/SIGHUP/SIGTERM/SIGKILL). Returns false when unknown. */
|
|
104
|
+
signal(id: string, signal: string): boolean {
|
|
105
|
+
const live = this.sessions.get(id)
|
|
106
|
+
if (live === undefined) return false
|
|
107
|
+
try { live.pty.kill(signal as Parameters<typeof live.pty.kill>[0]) } catch { return false }
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Close a session forcefully (SIGHUP semantics via kill). Returns false when unknown. */
|
|
112
|
+
close(id: string): boolean {
|
|
113
|
+
const live = this.sessions.get(id)
|
|
114
|
+
if (live === undefined) return false
|
|
115
|
+
try { live.pty.kill() } catch { /* already dead */ }
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The full session listing snapshot. */
|
|
120
|
+
list(): readonly TermSessionInfo[] {
|
|
121
|
+
return [...this.sessions.values()].map(({ info }) => ({ ...info }))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Close every session (route teardown). */
|
|
125
|
+
dispose(): void {
|
|
126
|
+
for (const live of this.sessions.values()) {
|
|
127
|
+
try { live.pty.kill() } catch { /* ignore */ }
|
|
128
|
+
}
|
|
129
|
+
this.sessions.clear()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /dsh-term/* route layer: JSON envelope (ok/error) for the PTY operations
|
|
3
|
+
* and one SSE stream (output/exit/start events) per client. Loopback-fenced
|
|
4
|
+
* like every other host route family — a terminal is arbitrary command
|
|
5
|
+
* execution, so only same-origin browser clients may reach it.
|
|
6
|
+
* @module dsh-term/host/routes
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
12
|
+
import type { TermEvent, TermSpawnRequest } from '../core/types.ts'
|
|
13
|
+
import type { PtyService } from './pty-service.ts'
|
|
14
|
+
import { isLoopbackRequest } from './loopback.ts'
|
|
15
|
+
|
|
16
|
+
/** JSON envelope mirrors the file-manager panel shape. */
|
|
17
|
+
type Envelope<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
|
18
|
+
|
|
19
|
+
const OK = (value: unknown): Envelope<unknown> => ({ ok: true, value })
|
|
20
|
+
const FAIL = (message: string, code = 'internal'): Envelope<never> => ({ ok: false, error: { code, message } })
|
|
21
|
+
const MALFORMED = FAIL('malformed request')
|
|
22
|
+
|
|
23
|
+
/** One SSE subscriber. */
|
|
24
|
+
interface Subscriber {
|
|
25
|
+
readonly res: ServerResponse
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Read a small JSON request body (bounded to 64 KiB). */
|
|
29
|
+
function readBody(req: IncomingMessage): Promise<unknown> {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const chunks: Buffer[] = []
|
|
32
|
+
let size = 0
|
|
33
|
+
req.on('data', (chunk: Buffer) => {
|
|
34
|
+
size += chunk.length
|
|
35
|
+
if (size > 64 * 1024) {
|
|
36
|
+
reject(new Error('request body too large'))
|
|
37
|
+
req.destroy()
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
chunks.push(chunk)
|
|
41
|
+
})
|
|
42
|
+
req.on('end', () => {
|
|
43
|
+
try {
|
|
44
|
+
resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf-8')))
|
|
45
|
+
} catch {
|
|
46
|
+
reject(new Error('invalid JSON'))
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
req.on('error', reject)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function json(res: ServerResponse, envelope: Envelope<unknown>, status = 200): void {
|
|
54
|
+
const body = JSON.stringify(envelope)
|
|
55
|
+
res.writeHead(status, {
|
|
56
|
+
'content-type': 'application/json; charset=utf-8',
|
|
57
|
+
'cache-control': 'no-store',
|
|
58
|
+
})
|
|
59
|
+
res.end(body)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register the /dsh-term routes.
|
|
64
|
+
* @param ctx - context carrying the webServer service.
|
|
65
|
+
* @param pty - the session registry.
|
|
66
|
+
* @returns route disposers.
|
|
67
|
+
*/
|
|
68
|
+
export function registerTermRoutes(ctx: Context, pty: PtyService): () => void {
|
|
69
|
+
const subscribers = new Set<Subscriber>()
|
|
70
|
+
const push = (event: TermEvent): void => {
|
|
71
|
+
for (const subscriber of subscribers) {
|
|
72
|
+
subscriber.res.write(`event: term\ndata: ${JSON.stringify(event)}\n\n`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pty.onOutput = (id, data) => push({ kind: 'output', id, data })
|
|
77
|
+
pty.onExit = (id, exitCode) => push({ kind: 'exit', id, exitCode })
|
|
78
|
+
|
|
79
|
+
const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
80
|
+
if (!isLoopbackRequest(req)) {
|
|
81
|
+
json(res, FAIL('loopback-only', 'forbidden'), 403)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const url = new URL(req.url ?? '/', 'http://dsh-term.local')
|
|
85
|
+
try {
|
|
86
|
+
if (req.method === 'GET' && url.pathname === '/dsh-term/list') {
|
|
87
|
+
json(res, OK({ sessions: pty.list() }))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (req.method !== 'POST') {
|
|
91
|
+
json(res, MALFORMED, 405)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
let payload: unknown
|
|
95
|
+
try {
|
|
96
|
+
payload = await readBody(req)
|
|
97
|
+
} catch {
|
|
98
|
+
json(res, MALFORMED, 400)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
switch (url.pathname) {
|
|
102
|
+
case '/dsh-term/spawn': {
|
|
103
|
+
const request = payload as Partial<TermSpawnRequest>
|
|
104
|
+
if (typeof request !== 'object' || request === null) {
|
|
105
|
+
json(res, MALFORMED, 400)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const session = pty.spawn({
|
|
109
|
+
name: typeof request.name === 'string' ? request.name : undefined,
|
|
110
|
+
cwd: typeof request.cwd === 'string' ? request.cwd : undefined,
|
|
111
|
+
shell: typeof request.shell === 'string' ? request.shell : undefined,
|
|
112
|
+
args: Array.isArray(request.args) ? request.args.filter((a): a is string => typeof a === 'string') : undefined,
|
|
113
|
+
cols: typeof request.cols === 'number' ? request.cols : undefined,
|
|
114
|
+
rows: typeof request.rows === 'number' ? request.rows : undefined,
|
|
115
|
+
})
|
|
116
|
+
push({ kind: 'start', session })
|
|
117
|
+
json(res, OK(session))
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
case '/dsh-term/write': {
|
|
121
|
+
const body = payload as { id?: unknown; data?: unknown }
|
|
122
|
+
if (typeof body?.id !== 'string' || typeof body?.data !== 'string') {
|
|
123
|
+
json(res, MALFORMED, 400)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
json(res, OK({ ok: pty.write(body.id, body.data) }))
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
case '/dsh-term/resize': {
|
|
130
|
+
const body = payload as { id?: unknown; cols?: unknown; rows?: unknown }
|
|
131
|
+
if (typeof body?.id !== 'string' || typeof body?.cols !== 'number' || typeof body?.rows !== 'number') {
|
|
132
|
+
json(res, MALFORMED, 400)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
json(res, OK({ ok: pty.resize(body.id, body.cols, body.rows) }))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
case '/dsh-term/signal': {
|
|
139
|
+
const body = payload as { id?: unknown; signal?: unknown }
|
|
140
|
+
if (typeof body?.id !== 'string' || typeof body?.signal !== 'string') {
|
|
141
|
+
json(res, MALFORMED, 400)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
json(res, OK({ ok: pty.signal(body.id, body.signal) }))
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
case '/dsh-term/close': {
|
|
148
|
+
const body = payload as { id?: unknown }
|
|
149
|
+
if (typeof body?.id !== 'string') {
|
|
150
|
+
json(res, MALFORMED, 400)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
json(res, OK({ ok: pty.close(body.id) }))
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
default:
|
|
157
|
+
json(res, MALFORMED, 404)
|
|
158
|
+
}
|
|
159
|
+
} catch (error: unknown) {
|
|
160
|
+
ctx.logger.warn(`dsh-term: route failed: ${String(error)}`)
|
|
161
|
+
json(res, FAIL('internal error'))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const sse = (req: IncomingMessage, res: ServerResponse): void => {
|
|
166
|
+
if (!isLoopbackRequest(req)) {
|
|
167
|
+
res.writeHead(403).end('loopback-only')
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
172
|
+
'cache-control': 'no-store',
|
|
173
|
+
'connection': 'keep-alive',
|
|
174
|
+
'x-accel-buffering': 'no',
|
|
175
|
+
})
|
|
176
|
+
res.write(': connected\n\n')
|
|
177
|
+
const subscriber: Subscriber = { res }
|
|
178
|
+
subscribers.add(subscriber)
|
|
179
|
+
const heartbeat = setInterval(() => {
|
|
180
|
+
if (subscriber.res.writableEnded) return
|
|
181
|
+
subscriber.res.write(': ping\n\n')
|
|
182
|
+
}, 15_000)
|
|
183
|
+
req.on('close', () => {
|
|
184
|
+
clearInterval(heartbeat)
|
|
185
|
+
subscribers.delete(subscriber)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const disposers = [
|
|
190
|
+
ctx.webServer.register({ kind: 'prefix', path: '/dsh-term', handler }),
|
|
191
|
+
ctx.webServer.register({ kind: 'exact', path: '/dsh-term/events', handler: sse }),
|
|
192
|
+
]
|
|
193
|
+
return () => {
|
|
194
|
+
for (const dispose of disposers) dispose()
|
|
195
|
+
for (const subscriber of subscribers) subscriber.res.end()
|
|
196
|
+
subscribers.clear()
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-term host half: mounts the PTY session service and the /dsh-term/*
|
|
3
|
+
* routes on the shared webserver. The browser half (src/client) renders the
|
|
4
|
+
* panel UI against these routes — no dsh source changes.
|
|
5
|
+
* @module dsh-term
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
10
|
+
import { mountOnce } from './mount-once.ts'
|
|
11
|
+
import { PtyService } from './host/pty-service.ts'
|
|
12
|
+
import { registerTermRoutes } from './host/routes.ts'
|
|
13
|
+
|
|
14
|
+
/** Required services: the route registry. */
|
|
15
|
+
export const inject = ['webServer']
|
|
16
|
+
|
|
17
|
+
/** Model-facing announcement: plugin presence. */
|
|
18
|
+
export const DSH_TERM_GUIDANCE = '本机已安装 dsh-term 插件(DSH Web GUI 的面板式终端):用户可在聊天区打开本地终端(真实 PTY,默认 powershell/bash),多标签并存、会话持久;用户提到「终端 / 打开终端 / 执行命令」时即指本插件,请据此协作。'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Mount the PTY service and its routes.
|
|
22
|
+
* @param ctx - context carrying the webServer service.
|
|
23
|
+
*/
|
|
24
|
+
export const apply = mountOnce('@lijian-ui/dsh-term', applyImpl)
|
|
25
|
+
|
|
26
|
+
function applyImpl(ctx: Context): void {
|
|
27
|
+
const pty = new PtyService()
|
|
28
|
+
// Route registration + pty teardown both ride the effect fiber: the effect
|
|
29
|
+
// callback runs immediately and its return value is the fiber disposer.
|
|
30
|
+
ctx.effect(() => {
|
|
31
|
+
const disposeRoutes = registerTermRoutes(ctx, pty)
|
|
32
|
+
return () => {
|
|
33
|
+
disposeRoutes()
|
|
34
|
+
pty.dispose()
|
|
35
|
+
}
|
|
36
|
+
}, 'dsh-term: routes + pty lifecycle')
|
|
37
|
+
}
|