@jhebe/web-terminal 0.2.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/README.md +86 -0
- package/bin/web-terminal.mjs +309 -0
- package/dist/index.d.ts +3 -0
- package/dist/protocol.d.ts +98 -0
- package/dist/terminal-client.d.ts +62 -0
- package/dist/terminal-view.d.ts +28 -0
- package/dist/web-terminal.css +2 -0
- package/dist/web-terminal.js +352 -0
- package/dist-server/server/history-buffer.d.ts +8 -0
- package/dist-server/server/history-buffer.js +59 -0
- package/dist-server/server/index.d.ts +2 -0
- package/dist-server/server/index.js +2 -0
- package/dist-server/server/session-manager.d.ts +51 -0
- package/dist-server/server/session-manager.js +274 -0
- package/dist-server/server/terminal-server.d.ts +16 -0
- package/dist-server/server/terminal-server.js +250 -0
- package/dist-server/src/protocol.d.ts +98 -0
- package/dist-server/src/protocol.js +199 -0
- package/package.json +59 -0
- package/scripts/prepare-node-pty.mjs +28 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { platform } from 'node:os';
|
|
3
|
+
import * as pty from 'node-pty';
|
|
4
|
+
import { HistoryBuffer } from './history-buffer.js';
|
|
5
|
+
export function createTerminalSessionManager(options = {}) {
|
|
6
|
+
return new InMemoryTerminalSessionManager(options);
|
|
7
|
+
}
|
|
8
|
+
class InMemoryTerminalSessionManager {
|
|
9
|
+
options;
|
|
10
|
+
records = new Map();
|
|
11
|
+
listeners = new Set();
|
|
12
|
+
historyLimitBytes;
|
|
13
|
+
idleTimeoutMs;
|
|
14
|
+
maxSessions;
|
|
15
|
+
maxObserversPerSession;
|
|
16
|
+
maxWriteBytes;
|
|
17
|
+
maxInputBytes;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
this.historyLimitBytes = options.historyLimitBytes ?? 256 * 1024;
|
|
21
|
+
this.idleTimeoutMs = options.idleTimeoutMs ?? 5 * 60 * 1000;
|
|
22
|
+
this.maxSessions = options.maxSessions ?? 100;
|
|
23
|
+
this.maxObserversPerSession = options.maxObserversPerSession ?? 32;
|
|
24
|
+
this.maxWriteBytes = options.maxWriteBytes ?? 64 * 1024;
|
|
25
|
+
this.maxInputBytes = options.maxInputBytes ?? 64 * 1024;
|
|
26
|
+
}
|
|
27
|
+
get sessions() {
|
|
28
|
+
return [...this.records.values()]
|
|
29
|
+
.map((session) => this.summarize(session))
|
|
30
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
31
|
+
}
|
|
32
|
+
open(options = {}) {
|
|
33
|
+
if (this.records.size >= this.maxSessions) {
|
|
34
|
+
throw new Error(`Session limit reached (${this.maxSessions}). Close or expire sessions before opening more.`);
|
|
35
|
+
}
|
|
36
|
+
const id = randomUUID();
|
|
37
|
+
const createdAt = new Date();
|
|
38
|
+
const terminal = pty.spawn(options.shell ?? this.options.shell ?? defaultShell(), options.shellArgs ?? this.options.shellArgs ?? [], {
|
|
39
|
+
name: 'xterm-256color',
|
|
40
|
+
cols: 80,
|
|
41
|
+
rows: 24,
|
|
42
|
+
cwd: options.cwd ?? this.options.cwd ?? process.cwd(),
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
...this.options.env,
|
|
46
|
+
...options.env,
|
|
47
|
+
TERM: 'xterm-256color',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const history = new HistoryBuffer(this.historyLimitBytes);
|
|
51
|
+
const outputSubscription = terminal.onData((data) => {
|
|
52
|
+
const current = this.records.get(id);
|
|
53
|
+
if (!current) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
current.lastActiveAt = new Date();
|
|
57
|
+
current.history.append(data);
|
|
58
|
+
current.attachment?.sink.output(data);
|
|
59
|
+
for (const observer of current.observers.values()) {
|
|
60
|
+
observer.output(data);
|
|
61
|
+
}
|
|
62
|
+
if (!current.attachment) {
|
|
63
|
+
this.scheduleExpiration(current);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const exitSubscription = terminal.onExit(({ exitCode, signal }) => {
|
|
67
|
+
const current = this.records.get(id);
|
|
68
|
+
if (!current) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
current.state = 'exited';
|
|
72
|
+
current.exitCode = exitCode;
|
|
73
|
+
current.lastActiveAt = new Date();
|
|
74
|
+
current.attachment?.sink.exit(exitCode, signal);
|
|
75
|
+
for (const observer of current.observers.values()) {
|
|
76
|
+
observer.exit(exitCode, signal);
|
|
77
|
+
}
|
|
78
|
+
if (!current.attachment) {
|
|
79
|
+
this.scheduleExpiration(current);
|
|
80
|
+
}
|
|
81
|
+
this.emit('exited', current);
|
|
82
|
+
});
|
|
83
|
+
const record = {
|
|
84
|
+
id,
|
|
85
|
+
name: options.name?.trim() || `Terminal ${this.records.size + 1}`,
|
|
86
|
+
createdAt,
|
|
87
|
+
lastActiveAt: createdAt,
|
|
88
|
+
state: 'running',
|
|
89
|
+
terminal,
|
|
90
|
+
history,
|
|
91
|
+
outputSubscription,
|
|
92
|
+
exitSubscription,
|
|
93
|
+
attachment: undefined,
|
|
94
|
+
observers: new Map(),
|
|
95
|
+
idleTimer: undefined,
|
|
96
|
+
};
|
|
97
|
+
this.records.set(id, record);
|
|
98
|
+
this.scheduleExpiration(record);
|
|
99
|
+
this.emit('opened', record);
|
|
100
|
+
return this.summarize(record);
|
|
101
|
+
}
|
|
102
|
+
close(sessionId) {
|
|
103
|
+
const session = this.requireSession(sessionId);
|
|
104
|
+
const summary = this.summarize(session);
|
|
105
|
+
this.remove(session);
|
|
106
|
+
this.emitSummary('closed', summary);
|
|
107
|
+
}
|
|
108
|
+
write(sessionId, data) {
|
|
109
|
+
const session = this.requireSession(sessionId);
|
|
110
|
+
if (session.state !== 'running') {
|
|
111
|
+
throw new Error('This session process has exited.');
|
|
112
|
+
}
|
|
113
|
+
assertPayloadWithinLimit(data, this.maxWriteBytes, 'write');
|
|
114
|
+
session.lastActiveAt = new Date();
|
|
115
|
+
session.terminal.write(data);
|
|
116
|
+
if (!session.attachment && session.observers.size === 0) {
|
|
117
|
+
this.scheduleExpiration(session);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
observe(sessionId, observerId, sink) {
|
|
121
|
+
const session = this.requireSession(sessionId);
|
|
122
|
+
if (!session.observers.has(observerId) &&
|
|
123
|
+
session.observers.size >= this.maxObserversPerSession) {
|
|
124
|
+
throw new Error(`Observer limit reached (${this.maxObserversPerSession}) for this session.`);
|
|
125
|
+
}
|
|
126
|
+
this.clearExpiration(session);
|
|
127
|
+
session.observers.set(observerId, sink);
|
|
128
|
+
session.lastActiveAt = new Date();
|
|
129
|
+
this.emit('attached', session);
|
|
130
|
+
return {
|
|
131
|
+
session: this.summarize(session),
|
|
132
|
+
history: session.history.read(),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
unobserve(sessionId, observerId) {
|
|
136
|
+
const session = this.records.get(sessionId);
|
|
137
|
+
if (!session || !session.observers.delete(observerId)) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
session.lastActiveAt = new Date();
|
|
141
|
+
this.scheduleExpiration(session);
|
|
142
|
+
this.emit('detached', session);
|
|
143
|
+
}
|
|
144
|
+
attach(sessionId, attachmentId, sink) {
|
|
145
|
+
const session = this.requireSession(sessionId);
|
|
146
|
+
if (session.attachment && session.attachment.id !== attachmentId) {
|
|
147
|
+
throw new Error('This session already has a writable view.');
|
|
148
|
+
}
|
|
149
|
+
this.clearExpiration(session);
|
|
150
|
+
session.attachment = { id: attachmentId, sink };
|
|
151
|
+
session.lastActiveAt = new Date();
|
|
152
|
+
this.emit('attached', session);
|
|
153
|
+
return {
|
|
154
|
+
session: this.summarize(session),
|
|
155
|
+
history: session.history.read(),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
detach(sessionId, attachmentId) {
|
|
159
|
+
const session = this.records.get(sessionId);
|
|
160
|
+
if (!session || session.attachment?.id !== attachmentId) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
session.attachment = undefined;
|
|
164
|
+
session.lastActiveAt = new Date();
|
|
165
|
+
this.scheduleExpiration(session);
|
|
166
|
+
this.emit('detached', session);
|
|
167
|
+
}
|
|
168
|
+
input(sessionId, attachmentId, data) {
|
|
169
|
+
const session = this.requireAttached(sessionId, attachmentId);
|
|
170
|
+
if (session.state !== 'running') {
|
|
171
|
+
throw new Error('This session process has exited.');
|
|
172
|
+
}
|
|
173
|
+
assertPayloadWithinLimit(data, this.maxInputBytes, 'input');
|
|
174
|
+
session.lastActiveAt = new Date();
|
|
175
|
+
session.terminal.write(data);
|
|
176
|
+
}
|
|
177
|
+
resize(sessionId, attachmentId, cols, rows) {
|
|
178
|
+
const session = this.requireAttached(sessionId, attachmentId);
|
|
179
|
+
if (session.state === 'running') {
|
|
180
|
+
session.terminal.resize(cols, rows);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
subscribe(listener) {
|
|
184
|
+
this.listeners.add(listener);
|
|
185
|
+
return () => this.listeners.delete(listener);
|
|
186
|
+
}
|
|
187
|
+
shutdown() {
|
|
188
|
+
for (const session of [...this.records.values()]) {
|
|
189
|
+
this.remove(session);
|
|
190
|
+
}
|
|
191
|
+
this.listeners.clear();
|
|
192
|
+
}
|
|
193
|
+
requireSession(sessionId) {
|
|
194
|
+
const session = this.records.get(sessionId);
|
|
195
|
+
if (!session) {
|
|
196
|
+
throw new Error(`Unknown terminal session: ${sessionId}`);
|
|
197
|
+
}
|
|
198
|
+
return session;
|
|
199
|
+
}
|
|
200
|
+
requireAttached(sessionId, attachmentId) {
|
|
201
|
+
const session = this.requireSession(sessionId);
|
|
202
|
+
if (session.attachment?.id !== attachmentId) {
|
|
203
|
+
throw new Error('This connection is not attached to the session.');
|
|
204
|
+
}
|
|
205
|
+
return session;
|
|
206
|
+
}
|
|
207
|
+
scheduleExpiration(session) {
|
|
208
|
+
this.clearExpiration(session);
|
|
209
|
+
if (this.idleTimeoutMs <= 0 ||
|
|
210
|
+
session.attachment ||
|
|
211
|
+
session.observers.size > 0) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
session.idleTimer = setTimeout(() => {
|
|
215
|
+
if (this.records.get(session.id) !== session ||
|
|
216
|
+
session.attachment ||
|
|
217
|
+
session.observers.size > 0) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const summary = this.summarize(session);
|
|
221
|
+
this.remove(session);
|
|
222
|
+
this.emitSummary('expired', summary);
|
|
223
|
+
}, this.idleTimeoutMs);
|
|
224
|
+
session.idleTimer.unref?.();
|
|
225
|
+
}
|
|
226
|
+
clearExpiration(session) {
|
|
227
|
+
if (session.idleTimer) {
|
|
228
|
+
clearTimeout(session.idleTimer);
|
|
229
|
+
session.idleTimer = undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
remove(session) {
|
|
233
|
+
this.records.delete(session.id);
|
|
234
|
+
this.clearExpiration(session);
|
|
235
|
+
session.outputSubscription.dispose();
|
|
236
|
+
session.exitSubscription.dispose();
|
|
237
|
+
if (session.state === 'running') {
|
|
238
|
+
session.terminal.kill();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
summarize(session) {
|
|
242
|
+
return {
|
|
243
|
+
id: session.id,
|
|
244
|
+
name: session.name,
|
|
245
|
+
state: session.state,
|
|
246
|
+
attached: Boolean(session.attachment),
|
|
247
|
+
observers: session.observers.size,
|
|
248
|
+
createdAt: session.createdAt.toISOString(),
|
|
249
|
+
lastActiveAt: session.lastActiveAt.toISOString(),
|
|
250
|
+
...(session.exitCode === undefined ? {} : { exitCode: session.exitCode }),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
emit(type, session) {
|
|
254
|
+
this.emitSummary(type, this.summarize(session));
|
|
255
|
+
}
|
|
256
|
+
emitSummary(type, session) {
|
|
257
|
+
const event = { type, session };
|
|
258
|
+
for (const listener of this.listeners) {
|
|
259
|
+
listener(event);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function defaultShell() {
|
|
264
|
+
if (platform() === 'win32') {
|
|
265
|
+
return process.env.COMSPEC ?? 'powershell.exe';
|
|
266
|
+
}
|
|
267
|
+
return process.env.SHELL ?? '/bin/sh';
|
|
268
|
+
}
|
|
269
|
+
function assertPayloadWithinLimit(value, limitBytes, operation) {
|
|
270
|
+
const bytes = Buffer.byteLength(value);
|
|
271
|
+
if (bytes > limitBytes) {
|
|
272
|
+
throw new Error(`Terminal ${operation} exceeds ${limitBytes} bytes (received ${bytes}).`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
|
2
|
+
import type { OpenSessionOptions, TerminalSessionManager } from './session-manager.js';
|
|
3
|
+
export type TerminalAction = 'open' | 'list' | 'attach' | 'detach' | 'close' | 'write' | 'observe';
|
|
4
|
+
export interface TerminalServerOptions {
|
|
5
|
+
server: HttpServer;
|
|
6
|
+
manager: TerminalSessionManager;
|
|
7
|
+
path?: string;
|
|
8
|
+
maxPayloadBytes?: number;
|
|
9
|
+
authorize?: (request: IncomingMessage, action: TerminalAction, sessionId?: string) => boolean | Promise<boolean>;
|
|
10
|
+
resolveOpenOptions?: (request: IncomingMessage, requestedName?: string) => OpenSessionOptions | Promise<OpenSessionOptions>;
|
|
11
|
+
onError?: (error: Error, request: IncomingMessage) => void;
|
|
12
|
+
}
|
|
13
|
+
export interface TerminalServer {
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare function attachTerminalServer(options: TerminalServerOptions): TerminalServer;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
3
|
+
import { parseClientMessage, } from '../src/protocol.js';
|
|
4
|
+
export function attachTerminalServer(options) {
|
|
5
|
+
const webSocketServer = new WebSocketServer({
|
|
6
|
+
server: options.server,
|
|
7
|
+
path: options.path ?? '/terminal',
|
|
8
|
+
maxPayload: options.maxPayloadBytes ?? 64 * 1024,
|
|
9
|
+
});
|
|
10
|
+
webSocketServer.on('connection', (socket, request) => {
|
|
11
|
+
const state = {
|
|
12
|
+
id: randomUUID(),
|
|
13
|
+
attachedSessionId: undefined,
|
|
14
|
+
closed: false,
|
|
15
|
+
snapshotQueue: Promise.resolve(),
|
|
16
|
+
observedSessionIds: new Set(),
|
|
17
|
+
};
|
|
18
|
+
let queue = Promise.resolve();
|
|
19
|
+
const unsubscribe = options.manager.subscribe(() => {
|
|
20
|
+
queueSessions(socket, request, state, options);
|
|
21
|
+
});
|
|
22
|
+
queueSessions(socket, request, state, options);
|
|
23
|
+
socket.on('message', (raw) => {
|
|
24
|
+
queue = queue
|
|
25
|
+
.then(() => handleMessage(raw.toString(), socket, request, state, options))
|
|
26
|
+
.catch((error) => {
|
|
27
|
+
reportError(socket, request, options, toError(error));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
socket.on('error', (error) => {
|
|
31
|
+
options.onError?.(toError(error), request);
|
|
32
|
+
socket.terminate();
|
|
33
|
+
});
|
|
34
|
+
socket.on('close', () => {
|
|
35
|
+
state.closed = true;
|
|
36
|
+
unsubscribe();
|
|
37
|
+
if (state.attachedSessionId) {
|
|
38
|
+
options.manager.detach(state.attachedSessionId, state.id);
|
|
39
|
+
state.attachedSessionId = undefined;
|
|
40
|
+
}
|
|
41
|
+
for (const sessionId of state.observedSessionIds) {
|
|
42
|
+
options.manager.unobserve(sessionId, state.id);
|
|
43
|
+
}
|
|
44
|
+
state.observedSessionIds.clear();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
close: () => new Promise((resolve, reject) => {
|
|
49
|
+
for (const client of webSocketServer.clients) {
|
|
50
|
+
client.close(1001, 'Server shutting down');
|
|
51
|
+
}
|
|
52
|
+
webSocketServer.close((error) => {
|
|
53
|
+
if (error) {
|
|
54
|
+
reject(error);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
resolve();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function handleMessage(raw, socket, request, state, options) {
|
|
64
|
+
const message = parseClientMessage(raw);
|
|
65
|
+
if (!message) {
|
|
66
|
+
send(socket, { type: 'error', message: 'Invalid client message.' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
await executeMessage(message, socket, request, state, options);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
reportError(socket, request, options, toError(error), 'requestId' in message ? message.requestId : undefined);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function executeMessage(message, socket, request, state, options) {
|
|
77
|
+
switch (message.type) {
|
|
78
|
+
case 'open': {
|
|
79
|
+
await requireAuthorization(options, request, 'open');
|
|
80
|
+
const openOptions = options.resolveOpenOptions
|
|
81
|
+
? await options.resolveOpenOptions(request, message.name)
|
|
82
|
+
: { ...(message.name === undefined ? {} : { name: message.name }) };
|
|
83
|
+
requireOpenConnection(socket, state);
|
|
84
|
+
const session = options.manager.open(openOptions);
|
|
85
|
+
send(socket, { type: 'opened', requestId: message.requestId, session });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
case 'attach': {
|
|
89
|
+
await requireAuthorization(options, request, 'attach', message.sessionId);
|
|
90
|
+
requireOpenConnection(socket, state);
|
|
91
|
+
const attachment = options.manager.attach(message.sessionId, state.id, {
|
|
92
|
+
output: (data) => send(socket, {
|
|
93
|
+
type: 'output',
|
|
94
|
+
sessionId: message.sessionId,
|
|
95
|
+
data,
|
|
96
|
+
}),
|
|
97
|
+
exit: (exitCode, signal) => send(socket, {
|
|
98
|
+
type: 'exit',
|
|
99
|
+
sessionId: message.sessionId,
|
|
100
|
+
exitCode,
|
|
101
|
+
...(signal === undefined ? {} : { signal }),
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
const previousSessionId = state.attachedSessionId;
|
|
105
|
+
state.attachedSessionId = message.sessionId;
|
|
106
|
+
if (previousSessionId && previousSessionId !== message.sessionId) {
|
|
107
|
+
options.manager.detach(previousSessionId, state.id);
|
|
108
|
+
}
|
|
109
|
+
send(socket, {
|
|
110
|
+
type: 'attached',
|
|
111
|
+
requestId: message.requestId,
|
|
112
|
+
session: attachment.session,
|
|
113
|
+
history: attachment.history,
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
case 'detach': {
|
|
118
|
+
const sessionId = message.sessionId;
|
|
119
|
+
await requireAuthorization(options, request, 'detach', sessionId);
|
|
120
|
+
requireOpenConnection(socket, state);
|
|
121
|
+
if (state.attachedSessionId === sessionId) {
|
|
122
|
+
options.manager.detach(sessionId, state.id);
|
|
123
|
+
state.attachedSessionId = undefined;
|
|
124
|
+
}
|
|
125
|
+
send(socket, {
|
|
126
|
+
type: 'detached',
|
|
127
|
+
requestId: message.requestId,
|
|
128
|
+
sessionId,
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
case 'close': {
|
|
133
|
+
await requireAuthorization(options, request, 'close', message.sessionId);
|
|
134
|
+
requireOpenConnection(socket, state);
|
|
135
|
+
options.manager.close(message.sessionId);
|
|
136
|
+
if (state.attachedSessionId === message.sessionId) {
|
|
137
|
+
state.attachedSessionId = undefined;
|
|
138
|
+
}
|
|
139
|
+
send(socket, {
|
|
140
|
+
type: 'closed',
|
|
141
|
+
requestId: message.requestId,
|
|
142
|
+
sessionId: message.sessionId,
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
case 'write': {
|
|
147
|
+
await requireAuthorization(options, request, 'write', message.sessionId);
|
|
148
|
+
requireOpenConnection(socket, state);
|
|
149
|
+
options.manager.write(message.sessionId, message.data);
|
|
150
|
+
send(socket, {
|
|
151
|
+
type: 'written',
|
|
152
|
+
requestId: message.requestId,
|
|
153
|
+
sessionId: message.sessionId,
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
case 'observe': {
|
|
158
|
+
await requireAuthorization(options, request, 'observe', message.sessionId);
|
|
159
|
+
requireOpenConnection(socket, state);
|
|
160
|
+
const observation = options.manager.observe(message.sessionId, state.id, {
|
|
161
|
+
output: (data) => send(socket, {
|
|
162
|
+
type: 'output',
|
|
163
|
+
sessionId: message.sessionId,
|
|
164
|
+
data,
|
|
165
|
+
}),
|
|
166
|
+
exit: (exitCode, signal) => send(socket, {
|
|
167
|
+
type: 'exit',
|
|
168
|
+
sessionId: message.sessionId,
|
|
169
|
+
exitCode,
|
|
170
|
+
...(signal === undefined ? {} : { signal }),
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
state.observedSessionIds.add(message.sessionId);
|
|
174
|
+
send(socket, {
|
|
175
|
+
type: 'observing',
|
|
176
|
+
requestId: message.requestId,
|
|
177
|
+
session: observation.session,
|
|
178
|
+
history: observation.history,
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
case 'unobserve': {
|
|
183
|
+
await requireAuthorization(options, request, 'observe', message.sessionId);
|
|
184
|
+
requireOpenConnection(socket, state);
|
|
185
|
+
options.manager.unobserve(message.sessionId, state.id);
|
|
186
|
+
state.observedSessionIds.delete(message.sessionId);
|
|
187
|
+
send(socket, {
|
|
188
|
+
type: 'unobserved',
|
|
189
|
+
requestId: message.requestId,
|
|
190
|
+
sessionId: message.sessionId,
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
case 'input':
|
|
195
|
+
options.manager.input(message.sessionId, state.id, message.data);
|
|
196
|
+
return;
|
|
197
|
+
case 'resize':
|
|
198
|
+
options.manager.resize(message.sessionId, state.id, message.cols, message.rows);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function sendSessions(socket, request, options) {
|
|
202
|
+
const visible = [];
|
|
203
|
+
for (const session of options.manager.sessions) {
|
|
204
|
+
if (!options.authorize ||
|
|
205
|
+
(await options.authorize(request, 'list', session.id))) {
|
|
206
|
+
visible.push(session);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
send(socket, { type: 'sessions', sessions: visible });
|
|
210
|
+
}
|
|
211
|
+
function queueSessions(socket, request, state, options) {
|
|
212
|
+
state.snapshotQueue = state.snapshotQueue
|
|
213
|
+
.then(async () => {
|
|
214
|
+
if (!state.closed) {
|
|
215
|
+
await sendSessions(socket, request, options);
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
.catch((error) => {
|
|
219
|
+
if (!state.closed) {
|
|
220
|
+
reportError(socket, request, options, toError(error));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async function requireAuthorization(options, request, action, sessionId) {
|
|
225
|
+
if (options.authorize &&
|
|
226
|
+
!(await options.authorize(request, action, sessionId))) {
|
|
227
|
+
throw new Error(`Not authorized to ${action} this terminal session.`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function reportError(socket, request, options, error, requestId) {
|
|
231
|
+
options.onError?.(error, request);
|
|
232
|
+
send(socket, {
|
|
233
|
+
type: 'error',
|
|
234
|
+
message: error.message,
|
|
235
|
+
...(requestId === undefined ? {} : { requestId }),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function send(socket, message) {
|
|
239
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
240
|
+
socket.send(JSON.stringify(message));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function requireOpenConnection(socket, state) {
|
|
244
|
+
if (state.closed || socket.readyState !== WebSocket.OPEN) {
|
|
245
|
+
throw new Error('Terminal connection closed before the operation completed.');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function toError(error) {
|
|
249
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
250
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export interface SessionSummary {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly state: 'running' | 'exited';
|
|
5
|
+
readonly attached: boolean;
|
|
6
|
+
readonly observers: number;
|
|
7
|
+
readonly createdAt: string;
|
|
8
|
+
readonly lastActiveAt: string;
|
|
9
|
+
readonly exitCode?: number;
|
|
10
|
+
}
|
|
11
|
+
export type ClientMessage = {
|
|
12
|
+
type: 'open';
|
|
13
|
+
requestId: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'close';
|
|
17
|
+
requestId: string;
|
|
18
|
+
sessionId: string;
|
|
19
|
+
} | {
|
|
20
|
+
type: 'attach';
|
|
21
|
+
requestId: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: 'detach';
|
|
25
|
+
requestId: string;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
} | {
|
|
28
|
+
type: 'write';
|
|
29
|
+
requestId: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
data: string;
|
|
32
|
+
} | {
|
|
33
|
+
type: 'observe';
|
|
34
|
+
requestId: string;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
} | {
|
|
37
|
+
type: 'unobserve';
|
|
38
|
+
requestId: string;
|
|
39
|
+
sessionId: string;
|
|
40
|
+
} | {
|
|
41
|
+
type: 'input';
|
|
42
|
+
sessionId: string;
|
|
43
|
+
data: string;
|
|
44
|
+
} | {
|
|
45
|
+
type: 'resize';
|
|
46
|
+
sessionId: string;
|
|
47
|
+
cols: number;
|
|
48
|
+
rows: number;
|
|
49
|
+
};
|
|
50
|
+
export type ServerMessage = {
|
|
51
|
+
type: 'sessions';
|
|
52
|
+
sessions: SessionSummary[];
|
|
53
|
+
} | {
|
|
54
|
+
type: 'opened';
|
|
55
|
+
requestId: string;
|
|
56
|
+
session: SessionSummary;
|
|
57
|
+
} | {
|
|
58
|
+
type: 'closed';
|
|
59
|
+
requestId: string;
|
|
60
|
+
sessionId: string;
|
|
61
|
+
} | {
|
|
62
|
+
type: 'attached';
|
|
63
|
+
requestId: string;
|
|
64
|
+
session: SessionSummary;
|
|
65
|
+
history: string;
|
|
66
|
+
} | {
|
|
67
|
+
type: 'detached';
|
|
68
|
+
requestId: string;
|
|
69
|
+
sessionId: string;
|
|
70
|
+
} | {
|
|
71
|
+
type: 'written';
|
|
72
|
+
requestId: string;
|
|
73
|
+
sessionId: string;
|
|
74
|
+
} | {
|
|
75
|
+
type: 'observing';
|
|
76
|
+
requestId: string;
|
|
77
|
+
session: SessionSummary;
|
|
78
|
+
history: string;
|
|
79
|
+
} | {
|
|
80
|
+
type: 'unobserved';
|
|
81
|
+
requestId: string;
|
|
82
|
+
sessionId: string;
|
|
83
|
+
} | {
|
|
84
|
+
type: 'output';
|
|
85
|
+
sessionId: string;
|
|
86
|
+
data: string;
|
|
87
|
+
} | {
|
|
88
|
+
type: 'exit';
|
|
89
|
+
sessionId: string;
|
|
90
|
+
exitCode: number;
|
|
91
|
+
signal?: number;
|
|
92
|
+
} | {
|
|
93
|
+
type: 'error';
|
|
94
|
+
message: string;
|
|
95
|
+
requestId?: string;
|
|
96
|
+
};
|
|
97
|
+
export declare function parseClientMessage(value: unknown): ClientMessage | undefined;
|
|
98
|
+
export declare function parseServerMessage(value: unknown): ServerMessage | undefined;
|