default.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. PageConfig, URLExt
  5. } from '@jupyterlab/coreutils';
  6. import {
  7. ArrayExt, each, map, toArray
  8. } from '@phosphor/algorithm';
  9. import {
  10. JSONPrimitive
  11. } from '@phosphor/coreutils';
  12. import {
  13. ISignal, Signal
  14. } from '@phosphor/signaling';
  15. import {
  16. ServerConnection
  17. } from '..';
  18. import {
  19. TerminalSession
  20. } from './terminal';
  21. /**
  22. * The url for the terminal service.
  23. */
  24. const TERMINAL_SERVICE_URL = 'api/terminals';
  25. /**
  26. * An implementation of a terminal interface.
  27. */
  28. export
  29. class DefaultTerminalSession implements TerminalSession.ISession {
  30. /**
  31. * Construct a new terminal session.
  32. */
  33. constructor(name: string, options: TerminalSession.IOptions = {}) {
  34. this._name = name;
  35. this.serverSettings = options.serverSettings || ServerConnection.makeSettings();
  36. this._readyPromise = this._initializeSocket();
  37. }
  38. /**
  39. * A signal emitted when the session is shut down.
  40. */
  41. get terminated(): Signal<this, void> {
  42. return this._terminated;
  43. }
  44. /**
  45. * A signal emitted when a message is received from the server.
  46. */
  47. get messageReceived(): ISignal<this, TerminalSession.IMessage> {
  48. return this._messageReceived;
  49. }
  50. /**
  51. * Get the name of the terminal session.
  52. */
  53. get name(): string {
  54. return this._name;
  55. }
  56. /**
  57. * Get the model for the terminal session.
  58. */
  59. get model(): TerminalSession.IModel {
  60. return { name: this._name };
  61. }
  62. /**
  63. * The server settings for the session.
  64. */
  65. readonly serverSettings: ServerConnection.ISettings;
  66. /**
  67. * Test whether the session is ready.
  68. */
  69. get isReady(): boolean {
  70. return this._isReady;
  71. }
  72. /**
  73. * A promise that fulfills when the session is ready.
  74. */
  75. get ready(): Promise<void> {
  76. return this._readyPromise;
  77. }
  78. /**
  79. * Test whether the session is disposed.
  80. */
  81. get isDisposed(): boolean {
  82. return this._isDisposed;
  83. }
  84. /**
  85. * Dispose of the resources held by the session.
  86. */
  87. dispose(): void {
  88. if (this._isDisposed) {
  89. return;
  90. }
  91. this.terminated.emit(void 0);
  92. this._isDisposed = true;
  93. if (this._ws) {
  94. this._ws.close();
  95. this._ws = null;
  96. }
  97. delete Private.running[this._url];
  98. Signal.clearData(this);
  99. }
  100. /**
  101. * Send a message to the terminal session.
  102. */
  103. send(message: TerminalSession.IMessage): void {
  104. if (this._isDisposed || !message.content) {
  105. return;
  106. }
  107. const msg = [message.type, ...message.content];
  108. const socket = this._ws;
  109. const value = JSON.stringify(msg);
  110. if (this._isReady && socket) {
  111. socket.send(value);
  112. return;
  113. }
  114. this.ready.then(() => {
  115. const socket = this._ws;
  116. if (socket) {
  117. socket.send(value);
  118. }
  119. });
  120. }
  121. /**
  122. * Reconnect to the terminal.
  123. *
  124. * @returns A promise that resolves when the terminal has reconnected.
  125. */
  126. reconnect(): Promise<void> {
  127. this._autoReconnectAttempt = 0;
  128. this._readyPromise = this._initializeSocket();
  129. return this._readyPromise;
  130. }
  131. /**
  132. * Shut down the terminal session.
  133. */
  134. shutdown(): Promise<void> {
  135. const { name, serverSettings } = this;
  136. return DefaultTerminalSession.shutdown(name, serverSettings);
  137. }
  138. /**
  139. * Clone the current session object.
  140. */
  141. clone(): TerminalSession.ISession {
  142. const { name, serverSettings } = this;
  143. return new DefaultTerminalSession(name, { serverSettings });
  144. }
  145. /**
  146. * Connect to the websocket.
  147. */
  148. private _initializeSocket = (): Promise<void> => {
  149. const name = this._name;
  150. let socket = this._ws;
  151. if (socket) {
  152. // Clear the websocket event handlers and the socket itself.
  153. socket.onopen = this._noOp;
  154. socket.onclose = this._noOp;
  155. socket.onerror = this._noOp;
  156. socket.onmessage = this._noOp;
  157. socket.close();
  158. this._ws = null;
  159. }
  160. this._isReady = false;
  161. return new Promise<void>((resolve, reject) => {
  162. const settings = this.serverSettings;
  163. const token = this.serverSettings.token;
  164. this._url = Private.getTermUrl(settings.baseUrl, this._name);
  165. Private.running[this._url] = this;
  166. let wsUrl = URLExt.join(settings.wsUrl, `terminals/websocket/${name}`);
  167. if (token) {
  168. wsUrl = wsUrl + `?token=${encodeURIComponent(token)}`;
  169. }
  170. socket = this._ws = new settings.WebSocket(wsUrl);
  171. socket.onmessage = (event: MessageEvent) => {
  172. if (this._isDisposed) {
  173. return;
  174. }
  175. const data = JSON.parse(event.data) as JSONPrimitive[];
  176. if (this._autoReconnectAttempt > 0) {
  177. // after reconnection, ignore all messages until 'setup' sent by terminado
  178. if (data[0] === 'setup') {
  179. this._autoReconnectAttempt = 0;
  180. }
  181. return;
  182. }
  183. this._messageReceived.emit({
  184. type: data[0] as TerminalSession.MessageType,
  185. content: data.slice(1)
  186. });
  187. };
  188. socket.onopen = (event: MessageEvent) => {
  189. if (!this._isDisposed) {
  190. this._isReady = true;
  191. resolve(undefined);
  192. }
  193. };
  194. socket.onerror = (event: Event) => {
  195. if (!this._isDisposed) {
  196. reject(event);
  197. }
  198. };
  199. socket.onclose = (event: CloseEvent) => {
  200. console.error(`Terminal websocket closed: ${event.code}`);
  201. this._reconnectSocket();
  202. };
  203. });
  204. }
  205. private _reconnectSocket = (): void => {
  206. if (this._isDisposed || !this._ws) {
  207. return;
  208. }
  209. if (this._autoReconnectAttempt < this._autoReconnectLimit) {
  210. this._isReady = false;
  211. let timeout = Math.pow(2, this._autoReconnectAttempt);
  212. console.error('Terminal websocket reconnecting in ' + timeout + ' seconds.');
  213. setTimeout(() => {
  214. this._initializeSocket().then(() => {
  215. console.error('Terminal websocket reconnected');
  216. }).catch((e) => {
  217. console.error(`Terminal websocket reconnecting error`);
  218. });
  219. }, 1e3 * timeout);
  220. this._autoReconnectAttempt += 1;
  221. } else {
  222. console.error(`Terminal websocket reconnecting aborted after ${this._autoReconnectAttempt} attemptions`);
  223. }
  224. }
  225. private _isDisposed = false;
  226. private _isReady = false;
  227. private _messageReceived = new Signal<this, TerminalSession.IMessage>(this);
  228. private _terminated = new Signal<this, void>(this);
  229. private _name: string;
  230. private _readyPromise: Promise<void>;
  231. private _url: string;
  232. private _ws: WebSocket | null = null;
  233. private _noOp = () => { /* no-op */};
  234. private _autoReconnectAttempt = 0;
  235. private _autoReconnectLimit = 7;
  236. }
  237. /**
  238. * The static namespace for `DefaultTerminalSession`.
  239. */
  240. export
  241. namespace DefaultTerminalSession {
  242. /**
  243. * Whether the terminal service is available.
  244. */
  245. export
  246. function isAvailable(): boolean {
  247. let available = String(PageConfig.getOption('terminalsAvailable'));
  248. return available.toLowerCase() === 'true';
  249. }
  250. /**
  251. * Start a new terminal session.
  252. *
  253. * @param options - The session options to use.
  254. *
  255. * @returns A promise that resolves with the session instance.
  256. */
  257. export
  258. function startNew(options: TerminalSession.IOptions = {}): Promise<TerminalSession.ISession> {
  259. if (!TerminalSession.isAvailable()) {
  260. throw Private.unavailableMsg;
  261. }
  262. let serverSettings = options.serverSettings || ServerConnection.makeSettings();
  263. let url = Private.getServiceUrl(serverSettings.baseUrl);
  264. let init = { method: 'POST' };
  265. return ServerConnection.makeRequest(url, init, serverSettings).then(response => {
  266. if (response.status !== 200) {
  267. throw new ServerConnection.ResponseError(response);
  268. }
  269. return response.json();
  270. }).then((data: TerminalSession.IModel) => {
  271. let name = data.name;
  272. return new DefaultTerminalSession(name, {...options, serverSettings });
  273. });
  274. }
  275. /*
  276. * Connect to a running session.
  277. *
  278. * @param name - The name of the target session.
  279. *
  280. * @param options - The session options to use.
  281. *
  282. * @returns A promise that resolves with the new session instance.
  283. *
  284. * #### Notes
  285. * If the session was already started via `startNew`, the existing
  286. * session object is used as the fulfillment value.
  287. *
  288. * Otherwise, if `options` are given, we resolve the promise after
  289. * confirming that the session exists on the server.
  290. *
  291. * If the session does not exist on the server, the promise is rejected.
  292. */
  293. export
  294. function connectTo(name: string, options: TerminalSession.IOptions = {}): Promise<TerminalSession.ISession> {
  295. if (!TerminalSession.isAvailable()) {
  296. return Promise.reject(Private.unavailableMsg);
  297. }
  298. let serverSettings = options.serverSettings || ServerConnection.makeSettings();
  299. let url = Private.getTermUrl(serverSettings.baseUrl, name);
  300. if (url in Private.running) {
  301. return Promise.resolve(Private.running[url].clone());
  302. }
  303. return listRunning(serverSettings).then(models => {
  304. let index = ArrayExt.findFirstIndex(models, model => {
  305. return model.name === name;
  306. });
  307. if (index !== -1) {
  308. let session = new DefaultTerminalSession(name, { ...options, serverSettings});
  309. return Promise.resolve(session);
  310. }
  311. return Promise.reject<TerminalSession.ISession>('Could not find session');
  312. });
  313. }
  314. /**
  315. * List the running terminal sessions.
  316. *
  317. * @param settings - The server settings to use.
  318. *
  319. * @returns A promise that resolves with the list of running session models.
  320. */
  321. export
  322. function listRunning(settings?: ServerConnection.ISettings): Promise<TerminalSession.IModel[]> {
  323. if (!TerminalSession.isAvailable()) {
  324. return Promise.reject(Private.unavailableMsg);
  325. }
  326. settings = settings || ServerConnection.makeSettings();
  327. let url = Private.getServiceUrl(settings.baseUrl);
  328. return ServerConnection.makeRequest(url, {}, settings).then(response => {
  329. if (response.status !== 200) {
  330. throw new ServerConnection.ResponseError(response);
  331. }
  332. return response.json();
  333. }).then((data: TerminalSession.IModel[]) => {
  334. if (!Array.isArray(data)) {
  335. throw new Error('Invalid terminal data');
  336. }
  337. // Update the local data store.
  338. let urls = toArray(map(data, item => {
  339. return URLExt.join(url, item.name);
  340. }));
  341. each(Object.keys(Private.running), runningUrl => {
  342. if (urls.indexOf(runningUrl) === -1) {
  343. let session = Private.running[runningUrl];
  344. session.dispose();
  345. }
  346. });
  347. return data;
  348. });
  349. }
  350. /**
  351. * Shut down a terminal session by name.
  352. *
  353. * @param name - The name of the target session.
  354. *
  355. * @param settings - The server settings to use.
  356. *
  357. * @returns A promise that resolves when the session is shut down.
  358. */
  359. export
  360. function shutdown(name: string, settings?: ServerConnection.ISettings): Promise<void> {
  361. if (!TerminalSession.isAvailable()) {
  362. return Promise.reject(Private.unavailableMsg);
  363. }
  364. settings = settings || ServerConnection.makeSettings();
  365. let url = Private.getTermUrl(settings.baseUrl, name);
  366. let init = { method: 'DELETE' };
  367. return ServerConnection.makeRequest(url, init, settings).then(response => {
  368. if (response.status === 404) {
  369. return response.json().then(data => {
  370. console.warn(data['message']);
  371. Private.killTerminal(url);
  372. });
  373. }
  374. if (response.status !== 204) {
  375. throw new ServerConnection.ResponseError(response);
  376. }
  377. Private.killTerminal(url);
  378. });
  379. }
  380. /**
  381. * Shut down all terminal sessions.
  382. *
  383. * @param settings - The server settings to use.
  384. *
  385. * @returns A promise that resolves when all the sessions are shut down.
  386. */
  387. export
  388. function shutdownAll(settings?: ServerConnection.ISettings): Promise<void> {
  389. settings = settings || ServerConnection.makeSettings();
  390. return listRunning(settings).then(running => {
  391. each(running, s => {
  392. shutdown(s.name, settings);
  393. });
  394. });
  395. }
  396. }
  397. /**
  398. * A namespace for private data.
  399. */
  400. namespace Private {
  401. /**
  402. * A mapping of running terminals by url.
  403. */
  404. export
  405. const running: { [key: string]: DefaultTerminalSession } = Object.create(null);
  406. /**
  407. * A promise returned for when terminals are unavailable.
  408. */
  409. export
  410. const unavailableMsg = 'Terminals Unavailable';
  411. /**
  412. * Get the url for a terminal.
  413. */
  414. export
  415. function getTermUrl(baseUrl: string, name: string): string {
  416. return URLExt.join(baseUrl, TERMINAL_SERVICE_URL, name);
  417. }
  418. /**
  419. * Get the base url.
  420. */
  421. export
  422. function getServiceUrl(baseUrl: string): string {
  423. return URLExt.join(baseUrl, TERMINAL_SERVICE_URL);
  424. }
  425. /**
  426. * Kill a terminal by url.
  427. */
  428. export
  429. function killTerminal(url: string): void {
  430. // Update the local data store.
  431. if (Private.running[url]) {
  432. let session = Private.running[url];
  433. session.dispose();
  434. }
  435. }
  436. }