@difizen/libro-terminal 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 +1 -0
- package/es/configuration.js +226 -0
- package/es/connection.d.ts +108 -0
- package/es/connection.d.ts.map +1 -0
- package/es/connection.js +385 -0
- package/es/index.d.ts +2 -0
- package/es/index.d.ts.map +1 -0
- package/es/index.js +1 -0
- package/es/manager.d.ts +104 -0
- package/es/manager.d.ts.map +1 -0
- package/es/manager.js +469 -0
- package/es/module.d.ts +3 -0
- package/es/module.d.ts.map +1 -0
- package/es/module.js +22 -0
- package/es/protocol.d.ts +87 -0
- package/es/protocol.d.ts.map +1 -0
- package/es/protocol.js +16 -0
- package/es/restapi.d.ts +11 -0
- package/es/restapi.d.ts.map +1 -0
- package/es/restapi.js +181 -0
- package/es/theme-service.js +183 -0
- package/es/view.d.ts +85 -0
- package/es/view.d.ts.map +1 -0
- package/es/view.js +317 -0
- package/package.json +63 -0
- package/src/configuration.ts +276 -0
- package/src/connection.ts +347 -0
- package/src/index.spec.ts +8 -0
- package/src/index.ts +1 -0
- package/src/manager.ts +280 -0
- package/src/module.ts +29 -0
- package/src/protocol.ts +102 -0
- package/src/restapi.ts +75 -0
- package/src/theme-service.ts +180 -0
- package/src/view.tsx +327 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import type { JSONPrimitive } from '@difizen/libro-common';
|
|
2
|
+
import { URL } from '@difizen/libro-common';
|
|
3
|
+
import type { ISettings } from '@difizen/libro-kernel';
|
|
4
|
+
import type { Disposable, Disposed, Event } from '@difizen/mana-app';
|
|
5
|
+
import { transient } from '@difizen/mana-app';
|
|
6
|
+
import { Deferred } from '@difizen/mana-app';
|
|
7
|
+
import { Emitter } from '@difizen/mana-app';
|
|
8
|
+
import { inject } from '@difizen/mana-app';
|
|
9
|
+
|
|
10
|
+
import { TerminalOption } from './protocol.js';
|
|
11
|
+
import type {
|
|
12
|
+
TerminalMessage,
|
|
13
|
+
TerminalModel,
|
|
14
|
+
TerminalConnectionStatus,
|
|
15
|
+
TerminalMessageType,
|
|
16
|
+
} from './protocol.js';
|
|
17
|
+
import { TerminalRestAPI } from './restapi.js';
|
|
18
|
+
|
|
19
|
+
@transient()
|
|
20
|
+
export class TerminalConnection implements Disposable, Disposed {
|
|
21
|
+
protected _onDisposed = new Emitter<void>();
|
|
22
|
+
protected _messageReceived = new Emitter<TerminalMessage>();
|
|
23
|
+
protected _connectionStatus: TerminalConnectionStatus = 'connecting';
|
|
24
|
+
protected _connectionStatusChanged = new Emitter<TerminalConnectionStatus>();
|
|
25
|
+
protected _name: string;
|
|
26
|
+
protected _reconnectTimeout?: NodeJS.Timeout = undefined;
|
|
27
|
+
protected _ws?: WebSocket = undefined;
|
|
28
|
+
protected _noOp = () => {
|
|
29
|
+
/* no-op */
|
|
30
|
+
};
|
|
31
|
+
protected _reconnectLimit = 7;
|
|
32
|
+
protected _reconnectAttempt = 0;
|
|
33
|
+
protected _pendingMessages: TerminalMessage[] = [];
|
|
34
|
+
@inject(TerminalRestAPI) terminalRestAPI: TerminalRestAPI;
|
|
35
|
+
|
|
36
|
+
disposed = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Construct a new terminal session.
|
|
40
|
+
*/
|
|
41
|
+
constructor(@inject(TerminalOption) options: TerminalOption & { name: string }) {
|
|
42
|
+
this._name = options.name;
|
|
43
|
+
this._createSocket();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A signal emitted when a message is received from the server.
|
|
48
|
+
*/
|
|
49
|
+
get messageReceived(): Event<TerminalMessage> {
|
|
50
|
+
return this._messageReceived.event;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get onDisposed(): Event<void> {
|
|
54
|
+
return this._onDisposed.event;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the name of the terminal session.
|
|
59
|
+
*/
|
|
60
|
+
get name(): string {
|
|
61
|
+
return this._name;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the model for the terminal session.
|
|
66
|
+
*/
|
|
67
|
+
get model(): TerminalModel {
|
|
68
|
+
return { name: this._name };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The server settings for the session.
|
|
73
|
+
*/
|
|
74
|
+
readonly serverSettings: ISettings;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Dispose of the resources held by the session.
|
|
78
|
+
*/
|
|
79
|
+
dispose(): void {
|
|
80
|
+
if (this.disposed) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.disposed = true;
|
|
84
|
+
this._updateConnectionStatus('disconnected');
|
|
85
|
+
this._clearSocket();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Send a message to the terminal session.
|
|
90
|
+
*
|
|
91
|
+
* #### Notes
|
|
92
|
+
* If the connection is down, the message will be queued for sending when
|
|
93
|
+
* the connection comes back up.
|
|
94
|
+
*/
|
|
95
|
+
send(message: TerminalMessage): void {
|
|
96
|
+
this._sendMessage(message);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Send a message on the websocket, or possibly queue for later sending.
|
|
101
|
+
*
|
|
102
|
+
* @param queue - whether to queue the message if it cannot be sent
|
|
103
|
+
*/
|
|
104
|
+
_sendMessage(message: TerminalMessage, queue = true): void {
|
|
105
|
+
if (this.disposed || !message.content) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (this.connectionStatus === 'connected' && this._ws) {
|
|
109
|
+
const msg = [message.type, ...message.content];
|
|
110
|
+
this._ws.send(JSON.stringify(msg));
|
|
111
|
+
} else if (queue) {
|
|
112
|
+
this._pendingMessages.push(message);
|
|
113
|
+
} else {
|
|
114
|
+
throw new Error(`Could not send message: ${JSON.stringify(message)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Send pending messages to the kernel.
|
|
120
|
+
*/
|
|
121
|
+
protected _sendPending(): void {
|
|
122
|
+
// We check to make sure we are still connected each time. For
|
|
123
|
+
// example, if a websocket buffer overflows, it may close, so we should
|
|
124
|
+
// stop sending messages.
|
|
125
|
+
while (this.connectionStatus === 'connected' && this._pendingMessages.length > 0) {
|
|
126
|
+
this._sendMessage(this._pendingMessages[0], false);
|
|
127
|
+
|
|
128
|
+
// We shift the message off the queue after the message is sent so that
|
|
129
|
+
// if there is an exception, the message is still pending.
|
|
130
|
+
this._pendingMessages.shift();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
toDisposeOnReconnect: Disposable | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* Reconnect to a terminal.
|
|
137
|
+
*/
|
|
138
|
+
reconnect(): Promise<void> {
|
|
139
|
+
this._errorIfDisposed();
|
|
140
|
+
const result = new Deferred<void>();
|
|
141
|
+
|
|
142
|
+
// Set up a listener for the connection status changing, which accepts or
|
|
143
|
+
// rejects after the retries are done.
|
|
144
|
+
const fulfill = (status: TerminalConnectionStatus) => {
|
|
145
|
+
if (status === 'connected') {
|
|
146
|
+
result.resolve();
|
|
147
|
+
this.toDisposeOnReconnect?.dispose();
|
|
148
|
+
} else if (status === 'disconnected') {
|
|
149
|
+
result.reject(new Error('Terminal connection disconnected'));
|
|
150
|
+
this.toDisposeOnReconnect?.dispose();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
this.toDisposeOnReconnect = this.connectionStatusChanged(fulfill);
|
|
154
|
+
|
|
155
|
+
// Reset the reconnect limit so we start the connection attempts fresh
|
|
156
|
+
this._reconnectAttempt = 0;
|
|
157
|
+
|
|
158
|
+
// Start the reconnection process, which will also clear any existing
|
|
159
|
+
// connection.
|
|
160
|
+
this._reconnect();
|
|
161
|
+
|
|
162
|
+
// Return the promise that should resolve on connection or reject if the
|
|
163
|
+
// retries don't work.
|
|
164
|
+
return result.promise;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Attempt a connection if we have not exhausted connection attempts.
|
|
169
|
+
*/
|
|
170
|
+
_reconnect(): void {
|
|
171
|
+
this._errorIfDisposed();
|
|
172
|
+
|
|
173
|
+
// Clear any existing reconnection attempt
|
|
174
|
+
clearTimeout(this._reconnectTimeout);
|
|
175
|
+
|
|
176
|
+
// Update the connection status and schedule a possible reconnection.
|
|
177
|
+
if (this._reconnectAttempt < this._reconnectLimit) {
|
|
178
|
+
this._updateConnectionStatus('connecting');
|
|
179
|
+
|
|
180
|
+
// The first reconnect attempt should happen immediately, and subsequent
|
|
181
|
+
// attempts should pick a random number in a growing range so that we
|
|
182
|
+
// don't overload the server with synchronized reconnection attempts
|
|
183
|
+
// across multiple kernels.
|
|
184
|
+
const timeout = getRandomIntInclusive(
|
|
185
|
+
0,
|
|
186
|
+
1e3 * (Math.pow(2, this._reconnectAttempt) - 1),
|
|
187
|
+
);
|
|
188
|
+
console.error(
|
|
189
|
+
`Connection lost, reconnecting in ${Math.floor(timeout / 1000)} seconds.`,
|
|
190
|
+
);
|
|
191
|
+
this._reconnectTimeout = setTimeout(this._createSocket, timeout);
|
|
192
|
+
this._reconnectAttempt += 1;
|
|
193
|
+
} else {
|
|
194
|
+
this._updateConnectionStatus('disconnected');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Clear the websocket event handlers and the socket itself.
|
|
198
|
+
this._clearSocket();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Forcefully clear the socket state.
|
|
203
|
+
*
|
|
204
|
+
* #### Notes
|
|
205
|
+
* This will clear all socket state without calling any handlers and will
|
|
206
|
+
* not update the connection status. If you call this method, you are
|
|
207
|
+
* responsible for updating the connection status as needed and recreating
|
|
208
|
+
* the socket if you plan to reconnect.
|
|
209
|
+
*/
|
|
210
|
+
protected _clearSocket(): void {
|
|
211
|
+
if (this._ws) {
|
|
212
|
+
// Clear the websocket event handlers and the socket itself.
|
|
213
|
+
this._ws.onopen = this._noOp;
|
|
214
|
+
this._ws.onclose = this._noOp;
|
|
215
|
+
this._ws.onerror = this._noOp;
|
|
216
|
+
this._ws.onmessage = this._noOp;
|
|
217
|
+
this._ws.close();
|
|
218
|
+
this._ws = undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Shut down the terminal session.
|
|
224
|
+
*/
|
|
225
|
+
async shutdown(): Promise<void> {
|
|
226
|
+
await this.terminalRestAPI.shutdown(this.name, this.serverSettings);
|
|
227
|
+
this.dispose();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Create the terminal websocket connection and add socket status handlers.
|
|
232
|
+
*
|
|
233
|
+
* #### Notes
|
|
234
|
+
* You are responsible for updating the connection status as appropriate.
|
|
235
|
+
*/
|
|
236
|
+
protected _createSocket = () => {
|
|
237
|
+
this._errorIfDisposed();
|
|
238
|
+
|
|
239
|
+
// Make sure the socket is clear
|
|
240
|
+
this._clearSocket();
|
|
241
|
+
|
|
242
|
+
// Update the connection status to reflect opening a new connection.
|
|
243
|
+
this._updateConnectionStatus('connecting');
|
|
244
|
+
|
|
245
|
+
const name = this._name;
|
|
246
|
+
const settings = this.serverSettings;
|
|
247
|
+
|
|
248
|
+
let url = URL.join(
|
|
249
|
+
settings.wsUrl,
|
|
250
|
+
'terminals',
|
|
251
|
+
'websocket',
|
|
252
|
+
encodeURIComponent(name),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// If token authentication is in use.
|
|
256
|
+
const token = settings.token;
|
|
257
|
+
if (settings.appendToken && token !== '') {
|
|
258
|
+
url = url + `?token=${encodeURIComponent(token)}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this._ws = new settings.WebSocket(url);
|
|
262
|
+
|
|
263
|
+
this._ws.onmessage = this._onWSMessage;
|
|
264
|
+
this._ws.onclose = this._onWSClose;
|
|
265
|
+
this._ws.onerror = this._onWSClose as (
|
|
266
|
+
this: WebSocket,
|
|
267
|
+
ev: globalThis.Event,
|
|
268
|
+
) => any;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Websocket messages events are defined as variables to bind `this`
|
|
272
|
+
protected _onWSMessage = (event: MessageEvent) => {
|
|
273
|
+
if (this.disposed) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const data = JSON.parse(event.data) as JSONPrimitive[];
|
|
277
|
+
|
|
278
|
+
// Handle a disconnect message.
|
|
279
|
+
if (data[0] === 'disconnect') {
|
|
280
|
+
this.dispose();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (this._connectionStatus === 'connecting') {
|
|
284
|
+
if (data[0] === 'setup') {
|
|
285
|
+
this._updateConnectionStatus('connected');
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
this._messageReceived.fire({
|
|
291
|
+
type: data[0] as TerminalMessageType,
|
|
292
|
+
content: data.slice(1),
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
protected _onWSClose = (event: CloseEvent) => {
|
|
297
|
+
console.warn(`Terminal websocket closed: ${event.code}`);
|
|
298
|
+
if (!this.disposed) {
|
|
299
|
+
this._reconnect();
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Handle connection status changes.
|
|
305
|
+
*/
|
|
306
|
+
protected _updateConnectionStatus(connectionStatus: TerminalConnectionStatus): void {
|
|
307
|
+
if (this._connectionStatus === connectionStatus) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
this._connectionStatus = connectionStatus;
|
|
312
|
+
|
|
313
|
+
// If we are not 'connecting', stop any reconnection attempts.
|
|
314
|
+
if (connectionStatus !== 'connecting') {
|
|
315
|
+
this._reconnectAttempt = 0;
|
|
316
|
+
clearTimeout(this._reconnectTimeout);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Send the pending messages if we just connected.
|
|
320
|
+
if (connectionStatus === 'connected') {
|
|
321
|
+
this._sendPending();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Notify others that the connection status changed.
|
|
325
|
+
this._connectionStatusChanged.fire(connectionStatus);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
protected _errorIfDisposed() {
|
|
329
|
+
if (this.disposed) {
|
|
330
|
+
throw new Error('Terminal connection is disposed');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
get connectionStatusChanged(): Event<TerminalConnectionStatus> {
|
|
335
|
+
return this._connectionStatusChanged.event;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
get connectionStatus(): TerminalConnectionStatus {
|
|
339
|
+
return this._connectionStatus;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function getRandomIntInclusive(min: number, max: number): number {
|
|
344
|
+
min = Math.ceil(min);
|
|
345
|
+
max = Math.floor(max);
|
|
346
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
347
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './module.js';
|
package/src/manager.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Copyright (c) Jupyter Development Team.
|
|
2
|
+
// Distributed under the terms of the Modified BSD License.
|
|
3
|
+
|
|
4
|
+
import { Poll } from '@difizen/libro-common';
|
|
5
|
+
import type { ISettings } from '@difizen/libro-kernel';
|
|
6
|
+
import { NetworkError } from '@difizen/libro-kernel';
|
|
7
|
+
import type { Disposable, Disposed, Event } from '@difizen/mana-app';
|
|
8
|
+
import { singleton } from '@difizen/mana-app';
|
|
9
|
+
import { Emitter, inject } from '@difizen/mana-app';
|
|
10
|
+
|
|
11
|
+
import type { TerminalConnection } from './connection.js';
|
|
12
|
+
import type { TerminalModel, TerminalOption } from './protocol.js';
|
|
13
|
+
import { TerminalConnectionFactory } from './protocol.js';
|
|
14
|
+
import { TerminalRestAPI } from './restapi.js';
|
|
15
|
+
|
|
16
|
+
@singleton()
|
|
17
|
+
export class TerminalManager implements Disposable, Disposed {
|
|
18
|
+
disposed = false;
|
|
19
|
+
protected _isReady = false;
|
|
20
|
+
protected _pollModels: Poll;
|
|
21
|
+
protected _terminalConnections = new Set<TerminalConnection>();
|
|
22
|
+
protected _ready: Promise<void>;
|
|
23
|
+
protected _runningChanged = new Emitter<TerminalModel[]>();
|
|
24
|
+
protected _connectionFailure = new Emitter<Error>();
|
|
25
|
+
// As an optimization, we unwrap the models to just store the names.
|
|
26
|
+
protected _names: string[] = [];
|
|
27
|
+
protected get _models(): TerminalModel[] {
|
|
28
|
+
return this._names.map((name) => {
|
|
29
|
+
return { name };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@inject(TerminalRestAPI) terminalRestAPI: TerminalRestAPI;
|
|
34
|
+
@inject(TerminalConnectionFactory)
|
|
35
|
+
terminalConnectionFactory: TerminalConnectionFactory;
|
|
36
|
+
/**
|
|
37
|
+
* Construct a new terminal manager.
|
|
38
|
+
*/
|
|
39
|
+
constructor() {
|
|
40
|
+
// Start polling with exponential backoff.
|
|
41
|
+
this._pollModels = new Poll({
|
|
42
|
+
auto: false,
|
|
43
|
+
factory: () => this.requestRunning(),
|
|
44
|
+
frequency: {
|
|
45
|
+
interval: 10 * 1000,
|
|
46
|
+
backoff: true,
|
|
47
|
+
max: 300 * 1000,
|
|
48
|
+
},
|
|
49
|
+
name: `@jupyterlab/services:TerminalManager#models`,
|
|
50
|
+
standby: 'when-hidden',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Initialize internal data.
|
|
54
|
+
this._ready = (async () => {
|
|
55
|
+
await this._pollModels.start();
|
|
56
|
+
await this._pollModels.tick;
|
|
57
|
+
this._isReady = true;
|
|
58
|
+
})();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The server settings of the manager.
|
|
63
|
+
*/
|
|
64
|
+
readonly serverSettings: ISettings;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Test whether the manager is ready.
|
|
68
|
+
*/
|
|
69
|
+
get isReady(): boolean {
|
|
70
|
+
return this._isReady;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A promise that fulfills when the manager is ready.
|
|
75
|
+
*/
|
|
76
|
+
get ready(): Promise<void> {
|
|
77
|
+
return this._ready;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A signal emitted when the running terminals change.
|
|
82
|
+
*/
|
|
83
|
+
get runningChanged(): Event<TerminalModel[]> {
|
|
84
|
+
return this._runningChanged.event;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A signal emitted when there is a connection failure.
|
|
89
|
+
*/
|
|
90
|
+
get connectionFailure(): Event<Error> {
|
|
91
|
+
return this._connectionFailure.event;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Dispose of the resources used by the manager.
|
|
96
|
+
*/
|
|
97
|
+
dispose(): void {
|
|
98
|
+
if (this.disposed) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this._names.length = 0;
|
|
102
|
+
this._terminalConnections.forEach((x) => x.dispose());
|
|
103
|
+
this._pollModels.dispose();
|
|
104
|
+
this.disposed = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* Connect to a running terminal.
|
|
109
|
+
*
|
|
110
|
+
* @param options - The options used to connect to the terminal.
|
|
111
|
+
*
|
|
112
|
+
* @returns The new terminal connection instance.
|
|
113
|
+
*
|
|
114
|
+
* #### Notes
|
|
115
|
+
* The manager `serverSettings` will be used.
|
|
116
|
+
*/
|
|
117
|
+
connectTo(options: { name: string }): TerminalConnection {
|
|
118
|
+
const terminalConnection = this.terminalConnectionFactory(options);
|
|
119
|
+
this._onStarted(terminalConnection);
|
|
120
|
+
if (!this._names.includes(options.name)) {
|
|
121
|
+
// We trust the user to connect to an existing session, but we verify
|
|
122
|
+
// asynchronously.
|
|
123
|
+
void this.refreshRunning().catch(() => {
|
|
124
|
+
/* no-op */
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return terminalConnection;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create an iterator over the most recent running terminals.
|
|
132
|
+
*
|
|
133
|
+
* @returns A new iterator over the running terminals.
|
|
134
|
+
*/
|
|
135
|
+
running(): IterableIterator<TerminalModel> {
|
|
136
|
+
return this._models[Symbol.iterator]();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Force a refresh of the running terminals.
|
|
141
|
+
*
|
|
142
|
+
* @returns A promise that with the list of running terminals.
|
|
143
|
+
*
|
|
144
|
+
* #### Notes
|
|
145
|
+
* This is intended to be called only in response to a user action,
|
|
146
|
+
* since the manager maintains its internal state.
|
|
147
|
+
*/
|
|
148
|
+
async refreshRunning(): Promise<void> {
|
|
149
|
+
await this._pollModels.refresh();
|
|
150
|
+
await this._pollModels.tick;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Create a new terminal session.
|
|
155
|
+
*
|
|
156
|
+
* @param options - The options used to create the terminal.
|
|
157
|
+
*
|
|
158
|
+
* @returns A promise that resolves with the terminal connection instance.
|
|
159
|
+
*
|
|
160
|
+
* #### Notes
|
|
161
|
+
* The manager `serverSettings` will be used unless overridden in the
|
|
162
|
+
* options.
|
|
163
|
+
*/
|
|
164
|
+
async startNew(options: TerminalOption): Promise<TerminalConnection> {
|
|
165
|
+
const model = await this.terminalRestAPI.startNew(options, this.serverSettings);
|
|
166
|
+
await this.refreshRunning();
|
|
167
|
+
return this.connectTo({ ...options, name: model.name });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Shut down a terminal session by name.
|
|
172
|
+
*/
|
|
173
|
+
async shutdown(name: string): Promise<void> {
|
|
174
|
+
await this.terminalRestAPI.shutdown(name, this.serverSettings);
|
|
175
|
+
await this.refreshRunning();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Shut down all terminal sessions.
|
|
180
|
+
*
|
|
181
|
+
* @returns A promise that resolves when all of the sessions are shut down.
|
|
182
|
+
*/
|
|
183
|
+
async shutdownAll(): Promise<void> {
|
|
184
|
+
// Update the list of models to make sure our list is current.
|
|
185
|
+
await this.refreshRunning();
|
|
186
|
+
|
|
187
|
+
// Shut down all models.
|
|
188
|
+
await Promise.all(
|
|
189
|
+
this._names.map((name) =>
|
|
190
|
+
this.terminalRestAPI.shutdown(name, this.serverSettings),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Update the list of models to clear out our state.
|
|
195
|
+
await this.refreshRunning();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Execute a request to the server to poll running terminals and update state.
|
|
200
|
+
*/
|
|
201
|
+
protected async requestRunning(): Promise<void> {
|
|
202
|
+
let models: TerminalModel[];
|
|
203
|
+
try {
|
|
204
|
+
models = await this.terminalRestAPI.listRunning(this.serverSettings);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// Handle network errors, as well as cases where we are on a
|
|
207
|
+
// JupyterHub and the server is not running. JupyterHub returns a
|
|
208
|
+
// 503 (<2.0) or 424 (>2.0) in that case.
|
|
209
|
+
if (
|
|
210
|
+
err instanceof NetworkError ||
|
|
211
|
+
(err as any).response?.status === 503 ||
|
|
212
|
+
(err as any).response?.status === 424
|
|
213
|
+
) {
|
|
214
|
+
this._connectionFailure.fire(err as any);
|
|
215
|
+
}
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (this.disposed) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const names = models.map(({ name }) => name).sort();
|
|
224
|
+
if (names === this._names) {
|
|
225
|
+
// Identical models list, so just return
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this._names = names;
|
|
230
|
+
this._terminalConnections.forEach((tc) => {
|
|
231
|
+
if (!names.includes(tc.name)) {
|
|
232
|
+
tc.dispose();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
this._runningChanged.fire(this._models);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Handle a session starting.
|
|
240
|
+
*/
|
|
241
|
+
protected _onStarted(terminalConnection: TerminalConnection): void {
|
|
242
|
+
this._terminalConnections.add(terminalConnection);
|
|
243
|
+
terminalConnection.onDisposed(() => {
|
|
244
|
+
this._onDisposed(terminalConnection);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Handle a session terminating.
|
|
250
|
+
*/
|
|
251
|
+
protected _onDisposed(terminalConnection: TerminalConnection): void {
|
|
252
|
+
this._terminalConnections.delete(terminalConnection);
|
|
253
|
+
// Update the running models to make sure we reflect the server state
|
|
254
|
+
void this.refreshRunning().catch(() => {
|
|
255
|
+
/* no-op */
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
getOrCreate = async (options: TerminalOption) => {
|
|
260
|
+
const { name, cwd } = options;
|
|
261
|
+
let connection;
|
|
262
|
+
if (name) {
|
|
263
|
+
const models = await this.terminalRestAPI.listRunning();
|
|
264
|
+
if (models.map((d) => d.name).includes(name)) {
|
|
265
|
+
// we are restoring a terminal widget and the corresponding terminal exists
|
|
266
|
+
// let's connect to it
|
|
267
|
+
connection = this.connectTo({ name });
|
|
268
|
+
} else {
|
|
269
|
+
// we are restoring a terminal widget but the corresponding terminal was closed
|
|
270
|
+
// let's start a new terminal with the original name
|
|
271
|
+
connection = await this.startNew({ name, cwd });
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
// we are creating a new terminal widget with a new terminal
|
|
275
|
+
// let the server choose the terminal name
|
|
276
|
+
connection = await this.startNew({ cwd });
|
|
277
|
+
}
|
|
278
|
+
return connection;
|
|
279
|
+
};
|
|
280
|
+
}
|
package/src/module.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ManaModule } from '@difizen/mana-app';
|
|
2
|
+
|
|
3
|
+
import { TerminalConfiguration } from './configuration.js';
|
|
4
|
+
import { TerminalConnection } from './connection.js';
|
|
5
|
+
import { TerminalManager } from './manager.js';
|
|
6
|
+
import { TerminalOption } from './protocol.js';
|
|
7
|
+
import { TerminalConnectionFactory } from './protocol.js';
|
|
8
|
+
import { TerminalRestAPI } from './restapi.js';
|
|
9
|
+
import { TerminalThemeService } from './theme-service.js';
|
|
10
|
+
import { LibroTerminalView } from './view.js';
|
|
11
|
+
|
|
12
|
+
export const TerminalModule = ManaModule.create().register(
|
|
13
|
+
TerminalConnection,
|
|
14
|
+
TerminalManager,
|
|
15
|
+
TerminalRestAPI,
|
|
16
|
+
{
|
|
17
|
+
token: TerminalConnectionFactory,
|
|
18
|
+
useFactory: (ctx) => {
|
|
19
|
+
return (options: TerminalOption) => {
|
|
20
|
+
const child = ctx.container.createChild();
|
|
21
|
+
child.register({ token: TerminalOption, useValue: options });
|
|
22
|
+
return child.get(TerminalConnection);
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
TerminalConfiguration,
|
|
27
|
+
TerminalThemeService,
|
|
28
|
+
LibroTerminalView,
|
|
29
|
+
);
|