start_jupyter_server.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. // Copyright (c) Jupyter Development Team.
  2. import { ChildProcess, spawn } from 'child_process';
  3. import merge from 'deepmerge';
  4. import * as fs from 'fs';
  5. import * as path from 'path';
  6. import { PageConfig, URLExt } from '@jupyterlab/coreutils';
  7. import { JSONObject, PromiseDelegate, UUID } from '@lumino/coreutils';
  8. import { sleep } from './common';
  9. /**
  10. * A Jupyter Server that runs as a child process.
  11. *
  12. * ### Notes
  13. * There can only be one running server at a time, since
  14. * PageConfig is global. Any classes that use `ServerConnection.ISettings`
  15. * such as `ServiceManager` should be instantiated after the server
  16. * has fully started so they pick up the right `PageConfig`.
  17. *
  18. * #### Example
  19. * ```typescript
  20. * const server = new JupyterServer();
  21. *
  22. * beforeAll(async () => {
  23. * await server.start();
  24. * });
  25. *
  26. * afterAll(async () => {
  27. * await server.shutdown();
  28. * });
  29. * ```
  30. *
  31. */
  32. export class JupyterServer {
  33. /**
  34. * Start the server.
  35. *
  36. * @returns A promise that resolves with the url of the server
  37. *
  38. * @throws Error if another server is still running.
  39. */
  40. async start(options: Partial<JupyterServer.IOptions> = {}): Promise<string> {
  41. if (Private.child !== null) {
  42. throw Error('Previous server was not disposed');
  43. }
  44. const startDelegate = new PromiseDelegate<string>();
  45. const env = {
  46. JUPYTER_CONFIG_DIR: Private.handleConfig(options),
  47. JUPYTER_DATA_DIR: Private.handleData(options),
  48. JUPYTER_RUNTIME_DIR: Private.mktempDir('jupyter_runtime'),
  49. IPYTHONDIR: Private.mktempDir('ipython'),
  50. PATH: process.env.PATH
  51. };
  52. // Create the child process for the server.
  53. const child = (Private.child = spawn('jupyter-lab', { env }));
  54. let started = false;
  55. // Handle server output.
  56. const handleOutput = (output: string) => {
  57. console.debug(output);
  58. if (started) {
  59. return;
  60. }
  61. const baseUrl = Private.handleStartup(output);
  62. if (baseUrl) {
  63. console.debug('Jupyter Server started');
  64. started = true;
  65. void Private.connect(baseUrl, startDelegate);
  66. }
  67. };
  68. child.stdout.on('data', data => {
  69. handleOutput(String(data));
  70. });
  71. child.stderr.on('data', data => {
  72. handleOutput(String(data));
  73. });
  74. const url = await startDelegate.promise;
  75. return url;
  76. }
  77. /**
  78. * Shut down the server, waiting for it to exit gracefully.
  79. */
  80. async shutdown(): Promise<void> {
  81. if (!Private.child) {
  82. return Promise.resolve(void 0);
  83. }
  84. const stopDelegate = new PromiseDelegate<void>();
  85. const child = Private.child;
  86. child.on('exit', code => {
  87. Private.child = null;
  88. if (code !== null && code !== 0) {
  89. stopDelegate.reject('child process exited with code ' + String(code));
  90. } else {
  91. stopDelegate.resolve(void 0);
  92. }
  93. });
  94. child.kill();
  95. window.setTimeout(() => {
  96. if (Private.child) {
  97. Private.child.kill(9);
  98. }
  99. }, 3000);
  100. return stopDelegate.promise;
  101. }
  102. }
  103. /**
  104. * A namespace for JupyterServer static values.
  105. */
  106. export namespace JupyterServer {
  107. /**
  108. * Options used to create a new JupyterServer instance.
  109. */
  110. export interface IOptions {
  111. /**
  112. * Additional Page Config values.
  113. */
  114. pageConfig: { [name: string]: string };
  115. /**
  116. * Additional traitlet config data.
  117. */
  118. configData: JSONObject;
  119. /**
  120. * Map of additional kernelspec names to kernel.json dictionaries
  121. */
  122. additionalKernelSpecs: JSONObject;
  123. }
  124. }
  125. /**
  126. * A namespace for module private data.
  127. */
  128. namespace Private {
  129. export let child: ChildProcess | null = null;
  130. /**
  131. * Make a temporary directory.
  132. *
  133. * @param suffix the last portion of the dir naem.
  134. */
  135. export function mktempDir(suffix: string): string {
  136. const pathPrefix = '/tmp/jupyterServer';
  137. if (!fs.existsSync(pathPrefix)) {
  138. fs.mkdirSync(pathPrefix);
  139. }
  140. return fs.mkdtempSync(`${pathPrefix}/${suffix}`);
  141. }
  142. /**
  143. * Install a spec in the data directory.
  144. */
  145. export function installSpec(dataDir: string, name: string, spec: any): void {
  146. const specDir = path.join(dataDir, 'kernels', name);
  147. fs.mkdirSync(specDir, { recursive: true });
  148. fs.writeFileSync(path.join(specDir, 'kernel.json'), JSON.stringify(spec));
  149. PageConfig.setOption(`__kernelSpec_${name}`, JSON.stringify(spec));
  150. }
  151. /**
  152. * Create and populate a notebook directory.
  153. */
  154. function createNotebookDir(): string {
  155. const nbDir = mktempDir('notebook');
  156. fs.mkdirSync(path.join(nbDir, 'src'));
  157. fs.writeFileSync(path.join(nbDir, 'src', 'temp.txt'), 'hello');
  158. const roFilepath = path.join(nbDir, 'src', 'readonly-temp.txt');
  159. fs.writeFileSync(roFilepath, 'hello from a ready only file', {
  160. mode: 0o444
  161. });
  162. return nbDir;
  163. }
  164. /**
  165. * Create a temporary directory for schemas.
  166. */
  167. function createAppDir(): string {
  168. const appDir = mktempDir('app');
  169. // Add a fake static/index.html for `ensure_app_check()`
  170. fs.mkdirSync(path.join(appDir, 'static'));
  171. fs.writeFileSync(path.join(appDir, 'static', 'index.html'), 'foo');
  172. // Add the apputils schema.
  173. const schemaDir = path.join(appDir, 'schemas');
  174. fs.mkdirSync(schemaDir, { recursive: true });
  175. const extensionDir = path.join(
  176. schemaDir,
  177. '@jupyterlab',
  178. 'apputils-extension'
  179. );
  180. fs.mkdirSync(extensionDir, { recursive: true });
  181. // Get schema content.
  182. const schema = {
  183. title: 'Theme',
  184. description: 'Theme manager settings.',
  185. properties: {
  186. theme: {
  187. type: 'string',
  188. title: 'Selected Theme',
  189. default: 'JupyterLab Light'
  190. }
  191. },
  192. type: 'object'
  193. };
  194. fs.writeFileSync(
  195. path.join(extensionDir, 'themes.json'),
  196. JSON.stringify(schema)
  197. );
  198. return appDir;
  199. }
  200. /**
  201. * Handle configuration.
  202. */
  203. export function handleConfig(
  204. options: Partial<JupyterServer.IOptions>
  205. ): string {
  206. // Set up configuration.
  207. const token = UUID.uuid4();
  208. PageConfig.setOption('token', token);
  209. PageConfig.setOption('terminalsAvailable', 'true');
  210. if (options.pageConfig) {
  211. Object.keys(options.pageConfig).forEach(key => {
  212. PageConfig.setOption(key, options.pageConfig![key]);
  213. });
  214. }
  215. const configDir = mktempDir('config');
  216. const configPath = path.join(configDir, 'jupyter_server_config.json');
  217. const root_dir = createNotebookDir();
  218. const app_dir = createAppDir();
  219. const user_settings_dir = mktempDir('settings');
  220. const workspaces_dir = mktempDir('workspaces');
  221. const configData = merge(
  222. {
  223. LabApp: {
  224. user_settings_dir,
  225. workspaces_dir,
  226. app_dir,
  227. open_browser: false,
  228. log_level: 'DEBUG'
  229. },
  230. ServerApp: {
  231. token,
  232. root_dir,
  233. log_level: 'DEBUG'
  234. },
  235. MultiKernelManager: {
  236. default_kernel_name: 'echo'
  237. },
  238. KernelManager: {
  239. shutdown_wait_time: 1.0
  240. }
  241. },
  242. options.configData || {}
  243. );
  244. PageConfig.setOption('__configData', JSON.stringify(configData));
  245. fs.writeFileSync(configPath, JSON.stringify(configData));
  246. return configDir;
  247. }
  248. /**
  249. * Handle data.
  250. */
  251. export function handleData(options: Partial<JupyterServer.IOptions>): string {
  252. const dataDir = mktempDir('data');
  253. // Install custom specs.
  254. installSpec(dataDir, 'echo', {
  255. argv: [
  256. 'python',
  257. '-m',
  258. 'jupyterlab.tests.echo_kernel',
  259. '-f',
  260. '{connection_file}'
  261. ],
  262. display_name: 'Echo Kernel',
  263. language: 'echo'
  264. });
  265. installSpec(dataDir, 'ipython', {
  266. argv: ['python', '-m', 'ipykernel_launcher', '-f', '{connection_file}'],
  267. display_name: 'Python 3',
  268. language: 'python'
  269. });
  270. if (options.additionalKernelSpecs) {
  271. Object.keys(options.additionalKernelSpecs).forEach(key => {
  272. installSpec(dataDir, key, options.additionalKernelSpecs![key]);
  273. });
  274. }
  275. return dataDir;
  276. }
  277. /**
  278. * Handle process startup.
  279. *
  280. * @param output the process output
  281. *
  282. * @returns The baseUrl of the server or `null`.
  283. */
  284. export function handleStartup(output: string): string | null {
  285. let baseUrl: string | null = null;
  286. output.split('\n').forEach(line => {
  287. const baseUrlMatch = line.match(/(http:\/\/localhost:\d+\/[^?]*)/);
  288. if (baseUrlMatch) {
  289. baseUrl = baseUrlMatch[1].replace('/lab', '');
  290. PageConfig.setOption('baseUrl', baseUrl);
  291. }
  292. });
  293. return baseUrl;
  294. }
  295. /**
  296. * Connect to the Jupyter server.
  297. */
  298. export async function connect(
  299. baseUrl: string,
  300. startDelegate: PromiseDelegate<string>
  301. ): Promise<void> {
  302. // eslint-disable-next-line
  303. while (true) {
  304. try {
  305. await fetch(URLExt.join(baseUrl, 'api'));
  306. startDelegate.resolve(baseUrl);
  307. return;
  308. } catch (e) {
  309. // spin until we can connect to the server.
  310. console.warn(e);
  311. await sleep(1000);
  312. }
  313. }
  314. }
  315. }