Skip to content

Commit

Permalink
feat(http-server): add http/2 support based on spdy
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed Nov 21, 2018
1 parent ba76ecf commit d9d9eca
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 31 deletions.
4 changes: 3 additions & 1 deletion packages/http-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
"copyright.owner": "IBM Corp.",
"license": "MIT",
"dependencies": {
"p-event": "^2.0.0"
"@types/spdy": "^3.4.4",
"p-event": "^2.0.0",
"spdy": "^4.0.0"
},
"devDependencies": {
"@loopback/build": "^1.0.1",
Expand Down
40 changes: 31 additions & 9 deletions packages/http-server/src/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {IncomingMessage, ServerResponse} from 'http';
import * as http from 'http';
import {IncomingMessage, ServerResponse} from 'http';
import * as https from 'https';
import {AddressInfo} from 'net';

import * as pEvent from 'p-event';
import * as spdy from 'spdy';

export type RequestListener = (
req: IncomingMessage,
Expand Down Expand Up @@ -46,21 +46,28 @@ export interface HttpsOptions extends ListenerOptions, https.ServerOptions {
protocol: 'https';
}

/**
* HTTP/2 server options based on `spdy`
*/
export interface Http2Options extends ListenerOptions, spdy.ServerOptions {
protocol: 'http2' | 'https';
}

/**
* Possible server options
*
* @export
* @type HttpServerOptions
*/
export type HttpServerOptions = HttpOptions | HttpsOptions;
export type HttpServerOptions = HttpOptions | HttpsOptions | Http2Options;

/**
* Supported protocols
*
* @export
* @type HttpProtocol
*/
export type HttpProtocol = 'http' | 'https'; // Will be extended to `http2` in the future
export type HttpProtocol = 'http' | 'https' | 'http2';

/**
* HTTP / HTTPS server used by LoopBack's RestServer
Expand All @@ -84,14 +91,22 @@ export class HttpServer {
*/
constructor(
requestListener: RequestListener,
serverOptions?: HttpServerOptions,
serverOptions: HttpServerOptions = {},
) {
this.requestListener = requestListener;
this.serverOptions = serverOptions;
this._port = serverOptions ? serverOptions.port || 0 : 0;
this._host = serverOptions ? serverOptions.host : undefined;
this._protocol = serverOptions ? serverOptions.protocol || 'http' : 'http';
if (this._protocol === 'https') {
if (
this._protocol === 'http2' ||
(this._protocol === 'https' && serverOptions.hasOwnProperty('spdy'))
) {
this.server = spdy.createServer(
this.serverOptions as spdy.ServerOptions,
this.requestListener,
);
} else if (this._protocol === 'https') {
this.server = https.createServer(
this.serverOptions as https.ServerOptions,
this.requestListener,
Expand All @@ -116,8 +131,14 @@ export class HttpServer {
*/
public async stop() {
if (!this.server) return;
this.server.close();
await pEvent(this.server, 'close');
if (!this.server.listening) return;
await new Promise<void>((resolve, reject) => {
this.server.close((err: unknown) => {
if (!err) resolve();
else reject(err);
});
});

this._listening = false;
}

Expand Down Expand Up @@ -153,7 +174,8 @@ export class HttpServer {
} else if (host === '0.0.0.0') {
host = '127.0.0.1';
}
return `${this._protocol}://${host}:${this.port}`;
const protocol = this._protocol === 'http2' ? 'https' : this._protocol;
return `${protocol}://${host}:${this.port}`;
}

/**
Expand Down
84 changes: 67 additions & 17 deletions packages/http-server/test/integration/http-server.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@
// Node module: @loopback/http-server
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
import {HttpServer, HttpOptions, HttpServerOptions} from '../../';
import {
supertest,
expect,
itSkippedOnTravis,
givenHttpServerConfig,
httpGetAsync,
httpsGetAsync,
givenHttpServerConfig,
itSkippedOnTravis,
supertest,
} from '@loopback/testlab';
import * as makeRequest from 'request-promise-native';
import {IncomingMessage, ServerResponse, Server} from 'http';
import * as path from 'path';
import * as fs from 'fs';
import {IncomingMessage, Server, ServerResponse} from 'http';
import {Agent} from 'https';
import * as path from 'path';
import * as makeRequest from 'request-promise-native';
import * as spdy from 'spdy';
import {HttpOptions, HttpServer, HttpServerOptions} from '../../';
import {Http2Options} from '../../src';

describe('HttpServer (integration)', () => {
let server: HttpServer | undefined;
Expand Down Expand Up @@ -156,29 +159,29 @@ describe('HttpServer (integration)', () => {

it('supports HTTPS protocol with key and certificate files', async () => {
const serverOptions = givenHttpServerConfig();
const httpsServer: HttpServer = givenHttpsServer(serverOptions);
await httpsServer.start();
const response = await httpsGetAsync(httpsServer.url);
server = givenHttpsServer(serverOptions);
await server.start();
const response = await httpsGetAsync(server.url);
expect(response.statusCode).to.equal(200);
});

it('supports HTTPS protocol with a pfx file', async () => {
const serverOptions = givenHttpServerConfig({
usePfx: true,
});
const httpsServer: HttpServer = givenHttpsServer(serverOptions);
await httpsServer.start();
const response = await httpsGetAsync(httpsServer.url);
server = givenHttpsServer(serverOptions);
await server.start();
const response = await httpsGetAsync(server.url);
expect(response.statusCode).to.equal(200);
});

itSkippedOnTravis('handles IPv6 loopback address in HTTPS', async () => {
const httpsServer: HttpServer = givenHttpsServer({
server = givenHttpsServer({
host: '::1',
});
await httpsServer.start();
expect(httpsServer.address!.family).to.equal('IPv6');
const response = await httpsGetAsync(httpsServer.url);
await server.start();
expect(server.address!.family).to.equal('IPv6');
const response = await httpsGetAsync(server.url);
expect(response.statusCode).to.equal(200);
});

Expand All @@ -196,6 +199,42 @@ describe('HttpServer (integration)', () => {
expect(server.url).to.equal(`http://127.0.0.1:${server.port}`);
});

it('supports HTTP/2 protocol with key and certificate files', async () => {
const serverOptions: Http2Options = Object.assign(
{
protocol: 'http2' as 'http2',
rejectUnauthorized: false,
spdy: {protocols: ['h2' as 'h2']},
},
givenHttpServerConfig(),
);
server = givenHttp2Server(serverOptions);
await server.start();

// http2 does not have its own url scheme
expect(server.url).to.match(/^https\:/);

const agent = spdy.createAgent({
rejectUnauthorized: false,
port: server.port,
host: server.host,

// Optional SPDY options
spdy: {
plain: false,
ssl: true,
},
}) as Agent;

const response = await httpsGetAsync(server.url, agent);
expect(response.statusCode).to.equal(200);

// We need to close the agent so that server.close() returns
// `@types/[email protected]` is not fully compatible with `[email protected]`
// tslint:disable-next-line:no-any
(agent as any).close();
});

function dummyRequestHandler(
req: IncomingMessage,
res: ServerResponse,
Expand Down Expand Up @@ -229,4 +268,15 @@ describe('HttpServer (integration)', () => {
}
return new HttpServer(dummyRequestHandler, options);
}

function givenHttp2Server(options: Http2Options): HttpServer {
const certDir = path.resolve(__dirname, '../../../fixtures');

const keyPath = path.join(certDir, 'key.pem');
const certPath = path.join(certDir, 'cert.pem');
options.key = fs.readFileSync(keyPath);
options.cert = fs.readFileSync(certPath);

return new HttpServer(dummyRequestHandler, options);
}
});
13 changes: 9 additions & 4 deletions packages/testlab/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@ export function httpGetAsync(urlString: string): Promise<IncomingMessage> {
* Async wrapper for making HTTPS GET requests
* @param urlString
*/
export function httpsGetAsync(urlString: string): Promise<IncomingMessage> {
const agent = new https.Agent({
rejectUnauthorized: false,
});
export function httpsGetAsync(
urlString: string,
agent?: https.Agent,
): Promise<IncomingMessage> {
agent =
agent ||
new https.Agent({
rejectUnauthorized: false,
});

const urlOptions = url.parse(urlString);
const options = {agent, ...urlOptions};
Expand Down

0 comments on commit d9d9eca

Please sign in to comment.