@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.
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ import {} from './index.js';
2
+ import 'reflect-metadata';
3
+
4
+ describe('libro-widget', () => {
5
+ it('#import', () => {
6
+ //
7
+ });
8
+ });
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
+ );