@linxin666/dsh-ssh 0.4.2 → 0.4.3
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/lib/client.js +303 -459
- package/lib/client.js.map +1 -1
- package/lib/index.js +707 -466
- package/lib/types/client/api.d.ts +27 -4
- package/lib/types/client/native-panel.d.ts +70 -0
- package/lib/types/client/panel/SshPanel.d.ts +1 -3
- package/lib/types/client/panel/TerminalTab.d.ts +6 -1
- package/lib/types/client/panel/controller.d.ts +71 -4
- package/lib/types/engine/terminal-sessions.d.ts +67 -0
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +12 -1
- package/lib/types/routes.d.ts +2 -0
- package/package.json +5 -2
- package/src/client/api.ts +52 -22
- package/src/client/index.ts +30 -8
- package/src/client/native-panel.tsx +141 -0
- package/src/client/panel/SshPanel.tsx +25 -28
- package/src/client/panel/TerminalTab.tsx +54 -14
- package/src/client/panel/controller.ts +126 -7
- package/src/client/panel/panel.module.css +8 -128
- package/src/engine/terminal-sessions.ts +280 -0
- package/src/index.ts +4 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +6 -1
- package/src/routes.ts +26 -107
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/mount.d.ts +0 -27
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -26
- package/src/client/body-mutations.ts +0 -133
- package/src/client/mount.tsx +0 -47
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -51
|
@@ -1,139 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-ssh panel styles. Scoped by the plugin's own data attributes so nothing
|
|
3
3
|
* leaks into the rest of the GUI; colors ride the dsh --dsw-* tokens so the
|
|
4
|
-
* panel follows the active theme (light/dark and skins).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* panel follows the active theme (light/dark and skins).
|
|
5
|
+
*
|
|
6
|
+
* The panel is a native main-slot page: the shell renders it only while this
|
|
7
|
+
* panel is selected, so this file carries no visibility rule and no rule over
|
|
8
|
+
* the conversation column, and the sidebar row is the shell's own panel row,
|
|
9
|
+
* so there is no injected entry row to style either.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
|
-
/* --- center-column takeover (global rules, attribute-scoped) ----------------- */
|
|
10
|
-
|
|
11
|
-
[data-pane='conversation'],
|
|
12
|
-
[class*='centerCol'] {
|
|
13
|
-
position: relative;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/* The panel container rides inside the conversation grid item as an extra
|
|
17
|
-
trailing child; hidden unless the panel is active. */
|
|
18
|
-
[data-dsh-ssh-view] {
|
|
19
|
-
position: absolute;
|
|
20
|
-
inset: 0;
|
|
21
|
-
display: none;
|
|
22
|
-
/* Above the conversation composer (z-index 7 in the 0.1.0-rc.6 shell) so the
|
|
23
|
-
panel and its modals paint over the input card, mirroring the task board
|
|
24
|
-
(issue #87). */
|
|
25
|
-
z-index: 60;
|
|
26
|
-
/* Opaque backdrop: the conversation subtree stays mounted under the panel.
|
|
27
|
-
Even if the hide rule below were defeated by a stronger host inline
|
|
28
|
-
style, this background keeps sticky code-block banners (host z-index 6)
|
|
29
|
-
from showing through the active panel (issue #100). */
|
|
30
|
-
background: var(--dsw-alias-bg-base);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/* The center column is single-occupant; the :not() guards keep the two
|
|
34
|
-
sibling panels (task board / ssh) from fighting over visibility if both
|
|
35
|
-
activation attributes ever coexist. */
|
|
36
|
-
html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-dsh-ssh-view] {
|
|
37
|
-
display: block;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/* While the panel is active, the conversation content underneath is hidden
|
|
41
|
-
(it stays mounted and stateful). The rule targets both the pane attribute
|
|
42
|
-
and the center-column class so the chat subtree (including the code-block
|
|
43
|
-
copy banner, issue #100) stays hidden whichever host container carries it. */
|
|
44
|
-
html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane='conversation'] > :not([data-dsh-ssh-view]),
|
|
45
|
-
html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol'] > :not([data-dsh-ssh-view]) {
|
|
46
|
-
display: none !important;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/* --- sidebar entry row ------------------------------------------------------- */
|
|
50
|
-
|
|
51
|
-
/* Same geometry as the shell's own panel rows (SidebarRoot .panelRow): 2px
|
|
52
|
-
inset with a 12px radius, 8px content padding, 36px row, 14/22 type, and the
|
|
53
|
-
primary ink — so an injected row and an official row line up column for
|
|
54
|
-
column instead of each carrying its own box. */
|
|
55
|
-
.entry {
|
|
56
|
-
box-sizing: border-box;
|
|
57
|
-
display: flex;
|
|
58
|
-
align-items: center;
|
|
59
|
-
gap: 8px;
|
|
60
|
-
min-height: 36px;
|
|
61
|
-
padding: 7px 8px;
|
|
62
|
-
margin: 0 2px;
|
|
63
|
-
background: transparent;
|
|
64
|
-
border: none;
|
|
65
|
-
border-radius: 12px;
|
|
66
|
-
color: var(--dsw-alias-label-primary);
|
|
67
|
-
cursor: pointer;
|
|
68
|
-
font: inherit;
|
|
69
|
-
font-size: 14px;
|
|
70
|
-
line-height: 22px;
|
|
71
|
-
text-align: left;
|
|
72
|
-
white-space: nowrap;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
.entry:hover {
|
|
76
|
-
background: var(--dsw-alias-interactive-bg-hover);
|
|
77
|
-
color: var(--dsw-alias-label-primary);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
.entry[data-active] {
|
|
81
|
-
background: var(--dsw-alias-interactive-bg-active);
|
|
82
|
-
color: var(--dsw-alias-label-primary);
|
|
83
|
-
font-weight: 600;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/* Icon box matches the shell's panel glyph exactly (16px), so the glyph column
|
|
87
|
-
and the label column land on the official rows' x positions. */
|
|
88
|
-
.entryIcon {
|
|
89
|
-
display: inline-flex;
|
|
90
|
-
align-items: center;
|
|
91
|
-
justify-content: center;
|
|
92
|
-
width: 16px;
|
|
93
|
-
height: 16px;
|
|
94
|
-
flex: none;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
.entryIcon svg {
|
|
98
|
-
display: block;
|
|
99
|
-
width: 16px;
|
|
100
|
-
height: 16px;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
.entryLabel {
|
|
104
|
-
overflow: hidden;
|
|
105
|
-
text-overflow: ellipsis;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/* Collapsed rail: icon-only, centered, matching the shell's 56px rail. The
|
|
109
|
-
shell asks its own panel rows for an 18px glyph on the rail, so the icon box
|
|
110
|
-
and the svg step up together with the 12px rail radius. */
|
|
111
|
-
:global([data-dsh-frame][data-sidebar-collapsed]) .entry,
|
|
112
|
-
:global([data-sidebar-collapsed]) .entry {
|
|
113
|
-
justify-content: center;
|
|
114
|
-
padding: 0;
|
|
115
|
-
width: 36px;
|
|
116
|
-
min-height: 36px;
|
|
117
|
-
margin: 0 auto 12px;
|
|
118
|
-
border-radius: 12px;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
:global([data-dsh-frame][data-sidebar-collapsed]) .entryIcon,
|
|
122
|
-
:global([data-sidebar-collapsed]) .entryIcon,
|
|
123
|
-
:global([data-dsh-frame][data-sidebar-collapsed]) .entryIcon svg,
|
|
124
|
-
:global([data-sidebar-collapsed]) .entryIcon svg {
|
|
125
|
-
width: 18px;
|
|
126
|
-
height: 18px;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
:global([data-dsh-frame][data-sidebar-collapsed]) .entryLabel,
|
|
130
|
-
:global([data-sidebar-collapsed]) .entryLabel {
|
|
131
|
-
display: none;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
12
|
/* --- panel frame -------------------------------------------------------------- */
|
|
135
13
|
|
|
136
14
|
.view {
|
|
15
|
+
height: 100%;
|
|
16
|
+
min-height: 0;
|
|
137
17
|
overflow: hidden;
|
|
138
18
|
}
|
|
139
19
|
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side terminal sessions for the web terminal.
|
|
3
|
+
*
|
|
4
|
+
* The PTY shell is owned here, not by the browser view: a view can detach
|
|
5
|
+
* (panel switch, page unmount, a dropped socket) and reattach by session id
|
|
6
|
+
* without tearing the remote shell down, which is what makes the terminal
|
|
7
|
+
* survival property the old DOM takeover used to provide. A session with no
|
|
8
|
+
* attached socket is reaped after an idle window, and an exited one is kept
|
|
9
|
+
* for a short grace window so a reattaching view still sees the final screen.
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from 'node:crypto'
|
|
12
|
+
import { WebSocket } from 'ws'
|
|
13
|
+
import type { KeyboardInteractiveHandler } from './connection-pool.ts'
|
|
14
|
+
import type { ShellSession } from './pty.ts'
|
|
15
|
+
import type { TerminalServerFrame } from '../protocol.ts'
|
|
16
|
+
|
|
17
|
+
/** Pause the shell when an attached socket's send buffer exceeds this… */
|
|
18
|
+
const BACKPRESSURE_HIGH_WATER = 1024 * 1024
|
|
19
|
+
|
|
20
|
+
/** …and resume once every attached socket drains below this. */
|
|
21
|
+
const BACKPRESSURE_LOW_WATER = 512 * 1024
|
|
22
|
+
|
|
23
|
+
/** Scrollback retained for a reattaching view (bytes of UTF-8 output). */
|
|
24
|
+
const DEFAULT_MAX_BUFFER_BYTES = 256 * 1024
|
|
25
|
+
|
|
26
|
+
/** A detached session is reaped after this long. */
|
|
27
|
+
const DEFAULT_IDLE_MS = 10 * 60 * 1000
|
|
28
|
+
|
|
29
|
+
/** An exited session is kept this long for a reattaching view. */
|
|
30
|
+
const DEFAULT_EXIT_GRACE_MS = 60 * 1000
|
|
31
|
+
|
|
32
|
+
/** One live terminal session and the sockets currently watching it. */
|
|
33
|
+
interface HostedTerminal {
|
|
34
|
+
id: string
|
|
35
|
+
alias: string
|
|
36
|
+
shell?: ShellSession
|
|
37
|
+
sockets: Set<WebSocket>
|
|
38
|
+
/** Bounded scrollback replayed to an attaching socket. */
|
|
39
|
+
buffer: string[]
|
|
40
|
+
bufferBytes: number
|
|
41
|
+
exited: boolean
|
|
42
|
+
exit?: { code: number | null; error?: string }
|
|
43
|
+
/** Pending keyboard-interactive challenge, whoever attached when it arrived. */
|
|
44
|
+
pendingAuthFinish?: (responses: string[]) => void
|
|
45
|
+
reapTimer?: ReturnType<typeof setTimeout>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the registry needs from the engine (swappable in tests). */
|
|
49
|
+
export interface TerminalSessionDeps {
|
|
50
|
+
/** Open one PTY shell for the alias (engine.openShell). */
|
|
51
|
+
openShell(alias: string, size: { cols: number; rows: number }, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<ShellSession>
|
|
52
|
+
/** @returns the current time (defaults to Date.now). */
|
|
53
|
+
now?: () => number
|
|
54
|
+
/** Bounded scrollback per session. */
|
|
55
|
+
maxBufferBytes?: number
|
|
56
|
+
/** Idle window before a detached session is reaped. */
|
|
57
|
+
idleMs?: number
|
|
58
|
+
/** Grace window an exited session is kept for reattaching views. */
|
|
59
|
+
exitGraceMs?: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The host's terminal session table: open one session per connect, attach any
|
|
64
|
+
* number of sockets, and keep the shell alive across detach.
|
|
65
|
+
*/
|
|
66
|
+
export class TerminalSessionRegistry {
|
|
67
|
+
private readonly sessions = new Map<string, HostedTerminal>()
|
|
68
|
+
private readonly deps: TerminalSessionDeps
|
|
69
|
+
private readonly maxBufferBytes: number
|
|
70
|
+
private readonly idleMs: number
|
|
71
|
+
private readonly exitGraceMs: number
|
|
72
|
+
|
|
73
|
+
constructor(deps: TerminalSessionDeps) {
|
|
74
|
+
this.deps = deps
|
|
75
|
+
this.maxBufferBytes = deps.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES
|
|
76
|
+
this.idleMs = deps.idleMs ?? DEFAULT_IDLE_MS
|
|
77
|
+
this.exitGraceMs = deps.exitGraceMs ?? DEFAULT_EXIT_GRACE_MS
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Open a new session for the alias and attach the requesting socket to it. */
|
|
81
|
+
async open(ws: WebSocket, alias: string, size: { cols: number; rows: number }): Promise<void> {
|
|
82
|
+
const session: HostedTerminal = {
|
|
83
|
+
id: randomBytes(9).toString('hex'),
|
|
84
|
+
alias,
|
|
85
|
+
sockets: new Set(),
|
|
86
|
+
buffer: [],
|
|
87
|
+
bufferBytes: 0,
|
|
88
|
+
exited: false,
|
|
89
|
+
}
|
|
90
|
+
this.sessions.set(session.id, session)
|
|
91
|
+
this.bind(ws, session)
|
|
92
|
+
try {
|
|
93
|
+
const shell = await this.deps.openShell(alias, size, this.keyboardInteractive(session))
|
|
94
|
+
if (!this.sessions.has(session.id)) {
|
|
95
|
+
// The session was ended (or the host torn down) while it connected.
|
|
96
|
+
shell.close()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
session.shell = shell
|
|
100
|
+
shell.onData = data => { this.pushOutput(session, data.toString('utf8')) }
|
|
101
|
+
shell.onExit = (code, error) => { this.finish(session, code, error) }
|
|
102
|
+
this.send(session, { type: 'ready', alias, sessionId: session.id })
|
|
103
|
+
} catch (error) {
|
|
104
|
+
this.sessions.delete(session.id)
|
|
105
|
+
this.send(session, { type: 'exit', code: null, error: error instanceof Error ? error.message : String(error) })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Attach a socket to an existing session, replaying its scrollback first.
|
|
111
|
+
* @returns false when the session is unknown (expired or already reaped).
|
|
112
|
+
*/
|
|
113
|
+
attach(ws: WebSocket, sessionId: string, size: { cols: number; rows: number }): boolean {
|
|
114
|
+
const session = this.sessions.get(sessionId)
|
|
115
|
+
if (session === undefined) return false
|
|
116
|
+
this.bind(ws, session)
|
|
117
|
+
this.clearReap(session)
|
|
118
|
+
for (const chunk of session.buffer) this.sendTo(session, ws, { type: 'output', data: chunk })
|
|
119
|
+
this.sendTo(session, ws, { type: 'ready', alias: session.alias, sessionId: session.id })
|
|
120
|
+
if (session.exited) {
|
|
121
|
+
this.sendTo(session, ws, { type: 'exit', code: session.exit?.code ?? null, error: session.exit?.error })
|
|
122
|
+
// Re-arm the grace window: this socket now holds the exited session open.
|
|
123
|
+
this.scheduleReap(session, this.exitGraceMs)
|
|
124
|
+
} else {
|
|
125
|
+
session.shell?.resize(Math.max(2, size.cols), Math.max(1, size.rows))
|
|
126
|
+
session.shell?.resume()
|
|
127
|
+
}
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** End every session (host teardown, plugin dispose). */
|
|
132
|
+
dispose(): void {
|
|
133
|
+
for (const session of [...this.sessions.values()]) this.end(session, false)
|
|
134
|
+
this.sessions.clear()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Number of live sessions (introspection for tests and diagnostics). */
|
|
138
|
+
size(): number {
|
|
139
|
+
return this.sessions.size
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Wire one socket's frames and lifecycle onto a session. */
|
|
143
|
+
private bind(ws: WebSocket, session: HostedTerminal): void {
|
|
144
|
+
session.sockets.add(ws)
|
|
145
|
+
ws.on('message', (data) => {
|
|
146
|
+
let frame: { type?: string; data?: string; cols?: number; rows?: number; responses?: string[] }
|
|
147
|
+
try {
|
|
148
|
+
frame = JSON.parse(String(data)) as typeof frame
|
|
149
|
+
} catch {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (frame.type === 'input') {
|
|
153
|
+
session.shell?.send(frame.data ?? '')
|
|
154
|
+
} else if (frame.type === 'resize') {
|
|
155
|
+
session.shell?.resize(Math.max(2, frame.cols ?? 80), Math.max(1, frame.rows ?? 24))
|
|
156
|
+
} else if (frame.type === 'auth_response') {
|
|
157
|
+
const finish = session.pendingAuthFinish
|
|
158
|
+
session.pendingAuthFinish = undefined
|
|
159
|
+
try { finish?.(frame.responses ?? []) } catch { /* the connect already failed */ }
|
|
160
|
+
} else if (frame.type === 'close') {
|
|
161
|
+
this.end(session, true)
|
|
162
|
+
} else if (frame.type === 'detach') {
|
|
163
|
+
this.detach(ws)
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
ws.on('close', () => { this.detach(ws) })
|
|
167
|
+
ws.on('error', () => { this.detach(ws) })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Remove a socket from its session, keeping the shell alive. */
|
|
171
|
+
private detach(ws: WebSocket): void {
|
|
172
|
+
for (const session of this.sessions.values()) {
|
|
173
|
+
if (!session.sockets.delete(ws)) continue
|
|
174
|
+
if (session.sockets.size === 0) {
|
|
175
|
+
// Nobody is watching: give the view a window to come back before the
|
|
176
|
+
// remote shell is torn down (or, for an exited one, before its final
|
|
177
|
+
// screen is dropped).
|
|
178
|
+
this.scheduleReap(session, session.exited ? this.exitGraceMs : this.idleMs)
|
|
179
|
+
}
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** End the session a socket is attached to (explicit close from the view). */
|
|
185
|
+
private end(session: HostedTerminal, notify: boolean): void {
|
|
186
|
+
this.clearReap(session)
|
|
187
|
+
if (this.sessions.delete(session.id) && notify) {
|
|
188
|
+
if (session.pendingAuthFinish !== undefined) {
|
|
189
|
+
const finish = session.pendingAuthFinish
|
|
190
|
+
session.pendingAuthFinish = undefined
|
|
191
|
+
try { finish([]) } catch { /* the connect already failed */ }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
try { session.shell?.close() } catch { /* channel gone */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Mark a session exited and keep it briefly so a reattaching view sees it. */
|
|
198
|
+
private finish(session: HostedTerminal, code: number | null, error?: string): void {
|
|
199
|
+
if (session.exited) return
|
|
200
|
+
session.exited = true
|
|
201
|
+
session.exit = { code, error }
|
|
202
|
+
this.send(session, { type: 'exit', code, error })
|
|
203
|
+
// The view has its exit frame; close the sockets so neither the browser
|
|
204
|
+
// nor the host holds a dead transport open. The session record itself
|
|
205
|
+
// survives the grace window, so a reattach still replays the final screen.
|
|
206
|
+
for (const ws of [...session.sockets]) {
|
|
207
|
+
session.sockets.delete(ws)
|
|
208
|
+
try { ws.close(1000) } catch { /* already closed */ }
|
|
209
|
+
}
|
|
210
|
+
this.scheduleReap(session, this.exitGraceMs)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Append output to the replay buffer, trimmed to the byte cap. */
|
|
214
|
+
private pushOutput(session: HostedTerminal, data: string): void {
|
|
215
|
+
session.buffer.push(data)
|
|
216
|
+
session.bufferBytes += Buffer.byteLength(data)
|
|
217
|
+
while (session.bufferBytes > this.maxBufferBytes && session.buffer.length > 1) {
|
|
218
|
+
const dropped = session.buffer.shift()
|
|
219
|
+
session.bufferBytes -= Buffer.byteLength(dropped ?? '')
|
|
220
|
+
}
|
|
221
|
+
this.send(session, { type: 'output', data })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Broadcast one frame to every attached socket, honoring backpressure. */
|
|
225
|
+
private send(session: HostedTerminal, frame: TerminalServerFrame): void {
|
|
226
|
+
for (const ws of session.sockets) this.sendTo(session, ws, frame)
|
|
227
|
+
this.applyBackpressure(session)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private sendTo(session: HostedTerminal, ws: WebSocket, frame: TerminalServerFrame): void {
|
|
231
|
+
if (ws.readyState !== WebSocket.OPEN) return
|
|
232
|
+
try { ws.send(JSON.stringify(frame)) } catch { /* socket raced away */ }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Pause while any attached socket is backed up; resume once all drained. */
|
|
236
|
+
private applyBackpressure(session: HostedTerminal): void {
|
|
237
|
+
const shell = session.shell
|
|
238
|
+
if (shell === undefined) return
|
|
239
|
+
let high = false
|
|
240
|
+
let low = true
|
|
241
|
+
for (const ws of session.sockets) {
|
|
242
|
+
if (ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) high = true
|
|
243
|
+
if (ws.bufferedAmount > BACKPRESSURE_LOW_WATER) low = false
|
|
244
|
+
}
|
|
245
|
+
if (high) shell.pause()
|
|
246
|
+
else if (low) shell.resume()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Answer a keyboard-interactive challenge through whoever is attached. */
|
|
250
|
+
private keyboardInteractive(session: HostedTerminal): KeyboardInteractiveHandler {
|
|
251
|
+
return (name, instructions, _lang, prompts, finish) => {
|
|
252
|
+
session.pendingAuthFinish = finish
|
|
253
|
+
this.send(session, {
|
|
254
|
+
type: 'auth_prompt',
|
|
255
|
+
name,
|
|
256
|
+
instructions,
|
|
257
|
+
prompts: prompts.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private scheduleReap(session: HostedTerminal, delayMs: number): void {
|
|
263
|
+
this.clearReap(session)
|
|
264
|
+
const timer = setTimeout(() => {
|
|
265
|
+
session.reapTimer = undefined
|
|
266
|
+
if (session.sockets.size > 0) return
|
|
267
|
+
this.end(session, false)
|
|
268
|
+
}, delayMs)
|
|
269
|
+
// Never hold the host process open for an idle terminal.
|
|
270
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
271
|
+
session.reapTimer = timer
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private clearReap(session: HostedTerminal): void {
|
|
275
|
+
if (session.reapTimer !== undefined) {
|
|
276
|
+
clearTimeout(session.reapTimer)
|
|
277
|
+
session.reapTimer = undefined
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -151,7 +151,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfig): void {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// The /api/dsh-ssh route family + terminal upgrade.
|
|
154
|
-
const { routes, upgrade } = makeRoutes({ store, engine })
|
|
154
|
+
const { routes, upgrade, terminalSessions } = makeRoutes({ store, engine })
|
|
155
155
|
let disposeRoutes: (() => void) | undefined
|
|
156
156
|
|
|
157
157
|
// Agent tools + their prompt sections.
|
|
@@ -200,6 +200,9 @@ function applyImpl(ctx: Context, config?: ResolvedConfig): void {
|
|
|
200
200
|
return () => {
|
|
201
201
|
for (const dispose of disposers) dispose()
|
|
202
202
|
upgradeDisposer()
|
|
203
|
+
// Dropping the routes also ends the terminal sessions they served:
|
|
204
|
+
// a live shell must not outlive the surface that can reach it.
|
|
205
|
+
terminalSessions.dispose()
|
|
203
206
|
}
|
|
204
207
|
},
|
|
205
208
|
'dsh-ssh: routes',
|
package/src/mount-once.ts
CHANGED
|
@@ -5,44 +5,138 @@
|
|
|
5
5
|
* the loader accepts a standalone install of the same package side by side;
|
|
6
6
|
* without this guard the second instance would still re-register the same
|
|
7
7
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
-
* and fail the boot. mountOnce makes
|
|
9
|
-
*
|
|
10
|
-
* package name in the client module host).
|
|
8
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
9
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
10
|
+
* deduped by package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
13
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
14
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
15
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
16
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
17
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
18
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
19
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
20
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
21
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
22
|
+
* restart recovered it).
|
|
23
|
+
*
|
|
24
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
25
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
26
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
27
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
28
|
+
* dedupes against it.
|
|
11
29
|
*
|
|
12
30
|
* The registry rides a global symbol so two module instances of the same
|
|
13
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
14
|
-
*
|
|
31
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
32
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
33
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
34
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
35
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
36
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
37
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
38
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
39
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
40
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
41
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
42
|
+
* mounted after it failed with "claims.get is not a function".
|
|
43
|
+
*
|
|
44
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
45
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
46
|
*/
|
|
17
47
|
|
|
48
|
+
/** Published cross-repository contract: package names currently mounted. */
|
|
18
49
|
const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
|
|
19
50
|
|
|
51
|
+
/** Additive key this guard owns: refused mounts waiting for the name. */
|
|
52
|
+
const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters')
|
|
53
|
+
|
|
54
|
+
/** The slice of a cordis context this guard touches. */
|
|
55
|
+
interface MountContext {
|
|
56
|
+
effect?: (effect: () => unknown) => unknown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One mount that was refused while another fiber owned the package name. */
|
|
60
|
+
interface PendingMount {
|
|
61
|
+
/** Replay the refused mount; a no-op once its own fiber has been disposed. */
|
|
62
|
+
run(): void
|
|
63
|
+
}
|
|
64
|
+
|
|
20
65
|
interface MountRegistry {
|
|
21
|
-
[MOUNTED]?:
|
|
66
|
+
[MOUNTED]?: unknown
|
|
67
|
+
[WAITERS]?: Map<string, PendingMount[]>
|
|
22
68
|
}
|
|
23
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The shared name registry, always a `Set` whatever another build stored here:
|
|
72
|
+
* a foreign value (an interim shape, a hand-written global) must not take every
|
|
73
|
+
* family plugin down with it.
|
|
74
|
+
* @returns the process-wide set of mounted package names.
|
|
75
|
+
*/
|
|
24
76
|
function mountedSet(): Set<string> {
|
|
25
77
|
const registry = globalThis as MountRegistry
|
|
26
|
-
|
|
78
|
+
const existing = registry[MOUNTED]
|
|
79
|
+
if (existing instanceof Set) return existing as Set<string>
|
|
80
|
+
const created = new Set<string>()
|
|
81
|
+
registry[MOUNTED] = created
|
|
82
|
+
return created
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Queue per package name for mounts refused while a holder was alive. */
|
|
86
|
+
function mountWaiters(): Map<string, PendingMount[]> {
|
|
87
|
+
const registry = globalThis as MountRegistry
|
|
88
|
+
return (registry[WAITERS] ??= new Map())
|
|
27
89
|
}
|
|
28
90
|
|
|
29
91
|
/**
|
|
30
92
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
-
* The first mount registers normally and
|
|
32
|
-
*
|
|
93
|
+
* The first mount registers normally and releases the name when its fiber
|
|
94
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
95
|
+
* then runs, unless its own fiber disposes first.
|
|
33
96
|
* @param packageName - npm package identity shared by every install source.
|
|
34
97
|
* @param fn - the original plugin apply.
|
|
35
98
|
* @returns an apply of the same shape.
|
|
36
99
|
*/
|
|
37
100
|
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
-
|
|
101
|
+
const mount = (...args: unknown[]): unknown => {
|
|
39
102
|
const mounted = mountedSet()
|
|
40
|
-
|
|
103
|
+
const ctx = args[0] as MountContext | undefined
|
|
104
|
+
if (mounted.has(packageName)) {
|
|
105
|
+
// Another fiber (the aggregate row, a standalone install, or a copy built
|
|
106
|
+
// from an older revision of this guard) owns the name. Queue instead of
|
|
107
|
+
// dropping: dropping lost the plugin for the rest of the process when the
|
|
108
|
+
// holder was disposed by a reload.
|
|
109
|
+
const waiters = mountWaiters()
|
|
110
|
+
const queue = waiters.get(packageName) ?? []
|
|
111
|
+
let alive = true
|
|
112
|
+
const pending: PendingMount = {
|
|
113
|
+
run: () => {
|
|
114
|
+
if (alive) mount(...args)
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
// A fiber that dies while it waits must not be replayed afterwards: its
|
|
118
|
+
// context is disposed and registering effects on it would throw.
|
|
119
|
+
ctx?.effect?.(() => () => {
|
|
120
|
+
alive = false
|
|
121
|
+
const index = queue.indexOf(pending)
|
|
122
|
+
if (index >= 0) queue.splice(index, 1)
|
|
123
|
+
})
|
|
124
|
+
queue.push(pending)
|
|
125
|
+
waiters.set(packageName, queue)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
41
128
|
mounted.add(packageName)
|
|
42
|
-
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
129
|
ctx?.effect?.(() => () => {
|
|
44
130
|
mounted.delete(packageName)
|
|
131
|
+
const waiters = mountWaiters()
|
|
132
|
+
const queue = waiters.get(packageName)
|
|
133
|
+
if (queue === undefined) return
|
|
134
|
+
waiters.delete(packageName)
|
|
135
|
+
// Defer the handover one microtask so the disposing mount's own effects
|
|
136
|
+
// (routes, ledger, timers) are torn down before the next one registers.
|
|
137
|
+
for (const waiter of queue.splice(0)) queueMicrotask(() => { waiter.run() })
|
|
45
138
|
})
|
|
46
139
|
return fn(...args)
|
|
47
|
-
}
|
|
140
|
+
}
|
|
141
|
+
return mount as T
|
|
48
142
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -188,7 +188,8 @@ export interface ApiErrorBody {
|
|
|
188
188
|
|
|
189
189
|
/** WebSocket terminal protocol frames (host -> client and client -> host). */
|
|
190
190
|
export type TerminalServerFrame =
|
|
191
|
-
|
|
191
|
+
/** The shell is up; `sessionId` reattaches to it after a view detach. */
|
|
192
|
+
| { type: 'ready'; alias: string; sessionId: string }
|
|
192
193
|
| { type: 'output'; data: string }
|
|
193
194
|
| { type: 'exit'; code: number | null; error?: string }
|
|
194
195
|
| {
|
|
@@ -202,6 +203,10 @@ export type TerminalClientFrame =
|
|
|
202
203
|
| { type: 'input'; data: string }
|
|
203
204
|
| { type: 'resize'; cols: number; rows: number }
|
|
204
205
|
| { type: 'auth_response'; responses: string[] }
|
|
206
|
+
/** Leave the socket while the host keeps the shell alive for a reattach. */
|
|
207
|
+
| { type: 'detach' }
|
|
208
|
+
/** End the session outright (the view's disconnect control). */
|
|
209
|
+
| { type: 'close' }
|
|
205
210
|
|
|
206
211
|
/** Route paths the client calls (shared literals). */
|
|
207
212
|
export const SSH_API_BASE = '/api/dsh-ssh' as const
|