From d9d9eca4d27dc9480097e6601433b8265dd17bd8 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 21 Nov 2018 13:03:29 -0800 Subject: [PATCH] feat(http-server): add http/2 support based on spdy --- packages/http-server/package.json | 4 +- packages/http-server/src/http-server.ts | 40 +++++++-- .../integration/http-server.integration.ts | 84 +++++++++++++++---- packages/testlab/src/request.ts | 13 ++- 4 files changed, 110 insertions(+), 31 deletions(-) diff --git a/packages/http-server/package.json b/packages/http-server/package.json index 481cea39fec3..36cd23788789 100644 --- a/packages/http-server/package.json +++ b/packages/http-server/package.json @@ -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", diff --git a/packages/http-server/src/http-server.ts b/packages/http-server/src/http-server.ts index 50e8c5ab7320..c56cf48a2785 100644 --- a/packages/http-server/src/http-server.ts +++ b/packages/http-server/src/http-server.ts @@ -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, @@ -46,13 +46,20 @@ 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 @@ -60,7 +67,7 @@ export type HttpServerOptions = HttpOptions | HttpsOptions; * @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 @@ -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, @@ -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((resolve, reject) => { + this.server.close((err: unknown) => { + if (!err) resolve(); + else reject(err); + }); + }); + this._listening = false; } @@ -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}`; } /** diff --git a/packages/http-server/test/integration/http-server.integration.ts b/packages/http-server/test/integration/http-server.integration.ts index 72c12099b533..6d2d90056cb7 100644 --- a/packages/http-server/test/integration/http-server.integration.ts +++ b/packages/http-server/test/integration/http-server.integration.ts @@ -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; @@ -156,9 +159,9 @@ 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); }); @@ -166,19 +169,19 @@ describe('HttpServer (integration)', () => { 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); }); @@ -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/spdy@3.x` is not fully compatible with `spdy@4.0.0` + // tslint:disable-next-line:no-any + (agent as any).close(); + }); + function dummyRequestHandler( req: IncomingMessage, res: ServerResponse, @@ -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); + } }); diff --git a/packages/testlab/src/request.ts b/packages/testlab/src/request.ts index 68ac3d170d71..ef9bc79ef987 100644 --- a/packages/testlab/src/request.ts +++ b/packages/testlab/src/request.ts @@ -22,10 +22,15 @@ export function httpGetAsync(urlString: string): Promise { * Async wrapper for making HTTPS GET requests * @param urlString */ -export function httpsGetAsync(urlString: string): Promise { - const agent = new https.Agent({ - rejectUnauthorized: false, - }); +export function httpsGetAsync( + urlString: string, + agent?: https.Agent, +): Promise { + agent = + agent || + new https.Agent({ + rejectUnauthorized: false, + }); const urlOptions = url.parse(urlString); const options = {agent, ...urlOptions};