@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
343 lines (295 loc) • 13.4 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2017 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
// *****************************************************************************
import * as path from 'path';
import * as http from 'http';
import * as https from 'https';
import * as express from 'express';
import * as yargs from 'yargs';
import * as fs from 'fs-extra';
import { inject, named, injectable, postConstruct } from 'inversify';
import { ContributionProvider, MaybePromise, Stopwatch } from '../common';
import { CliContribution } from './cli';
import { Deferred } from '../common/promise-util';
import { environment } from '../common/index';
import { AddressInfo } from 'net';
import { ApplicationPackage } from '@theia/application-package';
import { ProcessUtils } from './process-utils';
const APP_PROJECT_PATH = 'app-project-path';
const TIMER_WARNING_THRESHOLD = 50;
const DEFAULT_PORT = environment.electron.is() ? 0 : 3000;
const DEFAULT_HOST = 'localhost';
const DEFAULT_SSL = false;
export const BackendApplicationServer = Symbol('BackendApplicationServer');
/**
* This service is responsible for serving the frontend files.
*
* When not bound, `@theia/cli` generators will bind it on the fly to serve files according to its own layout.
*/
export interface BackendApplicationServer extends BackendApplicationContribution { }
export const BackendApplicationContribution = Symbol('BackendApplicationContribution');
/**
* Contribution for hooking into the backend lifecycle.
*/
export interface BackendApplicationContribution {
/**
* Called during the initialization of the backend application.
* Use this for functionality which has to run as early as possible.
*
* The implementation may be async, however it will still block the
* initialization step until it's resolved.
*
* @returns either `undefined` or a Promise resolving to `undefined`.
*/
initialize?(): MaybePromise<void>;
/**
* Called after the initialization of the backend application is complete.
* Use this to configure the Express app before it is started, for example
* to offer additional endpoints.
*
* The implementation may be async, however it will still block the
* configuration step until it's resolved.
*
* @param app the express application to configure.
*
* @returns either `undefined` or a Promise resolving to `undefined`.
*/
configure?(app: express.Application): MaybePromise<void>;
/**
* Called right after the server for the Express app is started.
* Use this to additionally configure the server or as ready-signal for your service.
*
* The implementation may be async, however it will still block the
* startup step until it's resolved.
*
* @param server the backend server running the express app.
*
* @returns either `undefined` or a Promise resolving to `undefined`.
*/
onStart?(server: http.Server | https.Server): MaybePromise<void>;
/**
* Called when the backend application shuts down. Contributions must perform only synchronous operations.
* Any kind of additional asynchronous work queued in the event loop will be ignored and abandoned.
*
* @param app the express application.
*/
onStop?(app?: express.Application): void;
}
export class BackendApplicationCliContribution implements CliContribution {
port: number;
hostname: string | undefined;
ssl: boolean | undefined;
cert: string | undefined;
certkey: string | undefined;
projectPath: string;
configure(conf: yargs.Argv): void {
conf.option('port', { alias: 'p', description: 'The port the backend server listens on.', type: 'number', default: DEFAULT_PORT });
conf.option('hostname', { alias: 'h', description: 'The allowed hostname for connections.', type: 'string', default: DEFAULT_HOST });
conf.option('ssl', { description: 'Use SSL (HTTPS), cert and certkey must also be set', type: 'boolean', default: DEFAULT_SSL });
conf.option('cert', { description: 'Path to SSL certificate.', type: 'string' });
conf.option('certkey', { description: 'Path to SSL certificate key.', type: 'string' });
conf.option(APP_PROJECT_PATH, { description: 'Sets the application project directory', default: this.appProjectPath() });
}
setArguments(args: yargs.Arguments): void {
this.port = args.port as number;
this.hostname = args.hostname as string;
this.ssl = args.ssl as boolean;
this.cert = args.cert as string;
this.certkey = args.certkey as string;
this.projectPath = args[APP_PROJECT_PATH] as string;
}
protected appProjectPath(): string {
if (environment.electron.is()) {
if (process.env.THEIA_APP_PROJECT_PATH) {
return process.env.THEIA_APP_PROJECT_PATH;
}
throw new Error('The \'THEIA_APP_PROJECT_PATH\' environment variable must be set when running in electron.');
}
return process.cwd();
}
}
/**
* The main entry point for Theia applications.
*/
export class BackendApplication {
protected readonly app: express.Application = express();
protected readonly applicationPackage: ApplicationPackage;
protected readonly processUtils: ProcessUtils;
protected readonly stopwatch: Stopwatch;
constructor(
protected readonly contributionsProvider: ContributionProvider<BackendApplicationContribution>,
protected readonly cliParams: BackendApplicationCliContribution) {
process.on('uncaughtException', error => {
this.handleUncaughtError(error);
});
// Workaround for Electron not installing a handler to ignore SIGPIPE error
// (https://github.com/electron/electron/issues/13254)
process.on('SIGPIPE', () => {
console.error(new Error('Unexpected SIGPIPE'));
});
/**
* Kill the current process tree on exit.
*/
function signalHandler(signal: NodeJS.Signals): never {
process.exit(1);
}
// Handles normal process termination.
process.on('exit', () => this.onStop());
// Handles `Ctrl+C`.
process.on('SIGINT', signalHandler);
// Handles `kill pid`.
process.on('SIGTERM', signalHandler);
}
protected async initialize(): Promise<void> {
for (const contribution of this.contributionsProvider.getContributions()) {
if (contribution.initialize) {
try {
await this.measure(contribution.constructor.name + '.initialize',
() => contribution.initialize!()
);
} catch (error) {
console.error('Could not initialize contribution', error);
}
}
}
}
protected async configure(): Promise<void> {
// Do not await the initialization because contributions are expected to handle
// concurrent initialize/configure in undefined order if they provide both
this.initialize();
this.app.get('*.js', this.serveGzipped.bind(this, 'text/javascript'));
this.app.get('*.js.map', this.serveGzipped.bind(this, 'application/json'));
this.app.get('*.css', this.serveGzipped.bind(this, 'text/css'));
this.app.get('*.wasm', this.serveGzipped.bind(this, 'application/wasm'));
this.app.get('*.gif', this.serveGzipped.bind(this, 'image/gif'));
this.app.get('*.png', this.serveGzipped.bind(this, 'image/png'));
this.app.get('*.svg', this.serveGzipped.bind(this, 'image/svg+xml'));
for (const contribution of this.contributionsProvider.getContributions()) {
if (contribution.configure) {
try {
await this.measure(contribution.constructor.name + '.configure',
() => contribution.configure!(this.app)
);
} catch (error) {
console.error('Could not configure contribution', error);
}
}
}
}
use(...handlers: express.Handler[]): void {
this.app.use(...handlers);
}
async start(aPort?: number, aHostname?: string): Promise<http.Server | https.Server> {
const hostname = aHostname !== undefined ? aHostname : this.cliParams.hostname;
const port = aPort !== undefined ? aPort : this.cliParams.port;
const deferred = new Deferred<http.Server | https.Server>();
let server: http.Server | https.Server;
if (this.cliParams.ssl) {
if (this.cliParams.cert === undefined) {
throw new Error('Missing --cert option, see --help for usage');
}
if (this.cliParams.certkey === undefined) {
throw new Error('Missing --certkey option, see --help for usage');
}
let key: Buffer;
let cert: Buffer;
try {
key = await fs.readFile(this.cliParams.certkey as string);
} catch (err) {
console.error("Can't read certificate key");
throw err;
}
try {
cert = await fs.readFile(this.cliParams.cert as string);
} catch (err) {
console.error("Can't read certificate");
throw err;
}
server = https.createServer({ key, cert }, this.app);
} else {
server = http.createServer(this.app);
}
server.on('error', error => {
deferred.reject(error);
/* The backend might run in a separate process,
* so we defer `process.exit` to let time for logging in the parent process */
setTimeout(process.exit, 0, 1);
});
server.listen(port, hostname, () => {
const scheme = this.cliParams.ssl ? 'https' : 'http';
console.info(`Theia app listening on ${scheme}://${hostname || 'localhost'}:${(server.address() as AddressInfo).port}.`);
deferred.resolve(server);
});
/* Allow any number of websocket servers. */
server.setMaxListeners(0);
for (const contribution of this.contributionsProvider.getContributions()) {
if (contribution.onStart) {
try {
await this.measure(contribution.constructor.name + '.onStart',
() => contribution.onStart!(server)
);
} catch (error) {
console.error('Could not start contribution', error);
}
}
}
return this.stopwatch.startAsync('server', 'Finished starting backend application', () => deferred.promise);
}
protected onStop(): void {
console.info('>>> Stopping backend contributions...');
for (const contrib of this.contributionsProvider.getContributions()) {
if (contrib.onStop) {
try {
contrib.onStop(this.app);
} catch (error) {
console.error('Could not stop contribution', error);
}
}
}
console.info('<<< All backend contributions have been stopped.');
this.processUtils.terminateProcessTree(process.pid);
}
protected async serveGzipped(contentType: string, req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
const acceptedEncodings = req.acceptsEncodings();
const gzUrl = `${req.url}.gz`;
const gzPath = path.join(this.applicationPackage.projectPath, 'lib', gzUrl);
if (acceptedEncodings.indexOf('gzip') === -1 || !(await fs.pathExists(gzPath))) {
next();
return;
}
req.url = gzUrl;
res.set('Content-Encoding', 'gzip');
res.set('Content-Type', contentType);
next();
}
protected async measure<T>(name: string, fn: () => MaybePromise<T>): Promise<T> {
return this.stopwatch.startAsync(name, `Backend ${name}`, fn, { thresholdMillis: TIMER_WARNING_THRESHOLD });
}
protected handleUncaughtError(error: Error): void {
if (error) {
console.error('Uncaught Exception: ', error.toString());
if (error.stack) {
console.error(error.stack);
}
}
}
}