diff --git a/packages/http-server/package.json b/packages/http-server/package.json index c13fd641dfd5..9a318283dbd3 100644 --- a/packages/http-server/package.json +++ b/packages/http-server/package.json @@ -26,7 +26,9 @@ "@types/node": "^10.11.2", "@types/p-event": "^1.3.0", "@types/request-promise-native": "^1.0.15", - "request-promise-native": "^1.0.5" + "@types/spdy": "^3.4.4", + "request-promise-native": "^1.0.5", + "spdy": "^4.0.0" }, "files": [ "README.md", diff --git a/packages/http-server/src/http-server.ts b/packages/http-server/src/http-server.ts index 50e8c5ab7320..cfa12ea61597 100644 --- a/packages/http-server/src/http-server.ts +++ b/packages/http-server/src/http-server.ts @@ -3,64 +3,17 @@ // 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 * as https from 'https'; import {AddressInfo} from 'net'; - import * as pEvent from 'p-event'; - -export type RequestListener = ( - req: IncomingMessage, - res: ServerResponse, -) => void; - -/** - * Basic HTTP server listener options - * - * @export - * @interface ListenerOptions - */ -export interface ListenerOptions { - host?: string; - port?: number; -} - -/** - * HTTP server options - * - * @export - * @interface HttpOptions - */ -export interface HttpOptions extends ListenerOptions { - protocol?: 'http'; -} - -/** - * HTTPS server options - * - * @export - * @interface HttpsOptions - */ -export interface HttpsOptions extends ListenerOptions, https.ServerOptions { - protocol: 'https'; -} - -/** - * Possible server options - * - * @export - * @type HttpServerOptions - */ -export type HttpServerOptions = HttpOptions | HttpsOptions; - -/** - * Supported protocols - * - * @export - * @type HttpProtocol - */ -export type HttpProtocol = 'http' | 'https'; // Will be extended to `http2` in the future +import { + HttpProtocol, + HttpServer, + HttpServerOptions, + RequestListener, + ProtocolServerFactory, +} from './types'; /** * HTTP / HTTPS server used by LoopBack's RestServer @@ -68,15 +21,18 @@ export type HttpProtocol = 'http' | 'https'; // Will be extended to `http2` in t * @export * @class HttpServer */ -export class HttpServer { +export class DefaultHttpServer implements HttpServer { private _port: number; private _host?: string; private _listening: boolean = false; - private _protocol: HttpProtocol; + protected _protocol: string; + private _urlScheme: string; private _address: AddressInfo; - private requestListener: RequestListener; - readonly server: http.Server | https.Server; - private serverOptions?: HttpServerOptions; + protected readonly requestListener: RequestListener; + protected _server: http.Server | https.Server; + protected readonly serverOptions: HttpServerOptions; + + protected protocolServerFactories: ProtocolServerFactory[]; /** * @param requestListener @@ -84,48 +40,67 @@ export class HttpServer { */ constructor( requestListener: RequestListener, - serverOptions?: HttpServerOptions, + serverOptions: HttpServerOptions = {}, + protocolServerFactories?: ProtocolServerFactory[], ) { this.requestListener = requestListener; + serverOptions = serverOptions || {}; 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') { - this.server = https.createServer( - this.serverOptions as https.ServerOptions, - this.requestListener, - ); - } else { - this.server = http.createServer(this.requestListener); + this._port = serverOptions.port || 0; + this._host = serverOptions.host || undefined; + this._protocol = serverOptions.protocol || 'http'; + this.protocolServerFactories = protocolServerFactories || [ + new HttpProtocolServerFactory(), + ]; + const server = this.createServer(); + this._server = server.server; + this._urlScheme = server.urlScheme; + } + + /** + * Create a server for the given protocol + */ + protected createServer() { + for (const factory of this.protocolServerFactories) { + if (factory.supports(this._protocol, this.serverOptions)) { + const server = factory.createServer( + this._protocol, + this.requestListener, + this.serverOptions, + ); + if (server) { + return server; + } + } } + throw new Error(`The ${this._protocol} protocol is not supported`); } /** * Starts the HTTP / HTTPS server */ public async start() { - this.server.listen(this._port, this._host); - await pEvent(this.server, 'listening'); + this._server.listen(this._port, this._host); + await pEvent(this._server, 'listening'); this._listening = true; - this._address = this.server.address() as AddressInfo; + this._address = this._server.address() as AddressInfo; } /** * Stops the HTTP / HTTPS server */ public async stop() { - if (!this.server) return; - this.server.close(); - await pEvent(this.server, 'close'); + if (!this._server) return; + this._server.close(); + await pEvent(this._server, 'close'); this._listening = false; } /** * Protocol of the HTTP / HTTPS server */ - public get protocol(): HttpProtocol { - return this._protocol; + public get protocol(): string { + return this._urlScheme || this._protocol; } /** @@ -147,13 +122,13 @@ export class HttpServer { */ public get url(): string { let host = this.host; - if (this._address.family === 'IPv6') { + if (this._address && this._address.family === 'IPv6') { if (host === '::') host = '::1'; host = `[${host}]`; } else if (host === '0.0.0.0') { host = '127.0.0.1'; } - return `${this._protocol}://${host}:${this.port}`; + return `${this.protocol}://${host}:${this.port}`; } /** @@ -163,6 +138,10 @@ export class HttpServer { return this._listening; } + public get server(): http.Server | https.Server { + return this._server; + } + /** * Address of the HTTP / HTTPS server */ @@ -170,3 +149,34 @@ export class HttpServer { return this._listening ? this._address : undefined; } } + +export class HttpProtocolServerFactory implements ProtocolServerFactory { + /** + * Supports http and https + * @param protocol + */ + supports(protocol: string) { + return protocol === 'http' || protocol === 'https'; + } + + /** + * Create a server for the given protocol + */ + createServer( + protocol: string, + requestListener: RequestListener, + serverOptions: HttpServerOptions, + ) { + if (protocol === 'https') { + const server = https.createServer( + serverOptions as https.ServerOptions, + requestListener, + ); + return {server, urlScheme: protocol}; + } else if (protocol === 'http') { + const server = http.createServer(requestListener); + return {server, urlScheme: protocol}; + } + throw new Error(`The ${protocol} protocol is not supported`); + } +} diff --git a/packages/http-server/src/index.ts b/packages/http-server/src/index.ts index 3c164c19d8a5..cab31ac61563 100644 --- a/packages/http-server/src/index.ts +++ b/packages/http-server/src/index.ts @@ -1 +1,7 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/http-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './types'; export * from './http-server'; diff --git a/packages/http-server/src/types.ts b/packages/http-server/src/types.ts new file mode 100644 index 000000000000..ef3b21832bac --- /dev/null +++ b/packages/http-server/src/types.ts @@ -0,0 +1,110 @@ +import {AddressInfo} from 'net'; +import * as http from 'http'; +import * as https from 'https'; +import {IncomingMessage, ServerResponse} from 'http'; + +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/http-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export type RequestListener = ( + req: IncomingMessage, + res: ServerResponse, +) => void; + +/** + * Basic HTTP server listener options + * + * @export + * @interface ListenerOptions + */ +export interface ListenerOptions { + host?: string; + port?: number; +} + +/** + * HTTP server options + * + * @export + * @interface HttpOptions + */ +export interface HttpOptions extends ListenerOptions { + protocol?: 'http'; +} + +/** + * HTTPS server options + * + * @export + * @interface HttpsOptions + */ +export interface HttpsOptions extends ListenerOptions, https.ServerOptions { + protocol: 'https'; +} + +/** + * HTTP/2 server options + * + * @export + * @interface Http2Options + */ +export interface Http2Options extends ListenerOptions { + protocol: 'http2'; + // Other options for a module like https://github.com/spdy-http2/node-spdy + [name: string]: unknown; +} + +/** + * Possible server options + * + * @export + * @type HttpServerOptions + */ +export type HttpServerOptions = HttpOptions | HttpsOptions | Http2Options; + +/** + * Supported protocols + * + * @export + * @type HttpProtocol + */ +export type HttpProtocol = 'http' | 'https' | 'http2'; + +/** + * HTTP / HTTPS server used by LoopBack's RestServer + * + * @export + * @class HttpServer + */ +export interface HttpServer { + readonly server: http.Server | https.Server; + readonly protocol: string; + readonly port: number; + readonly host: string | undefined; + readonly url: string; + readonly listening: boolean; + readonly address: AddressInfo | undefined; + + start(): Promise; + stop(): Promise; +} + +export interface ProtocolServer { + server: http.Server | https.Server; + /** + * Scheme for the URL + */ + urlScheme: string; +} + +export interface ProtocolServerFactory { + supports(protocol: string, serverOptions: HttpServerOptions): boolean; + + createServer( + protocol: string, + requestListener: RequestListener, + serverOptions: HttpServerOptions, + ): ProtocolServer; +} diff --git a/packages/http-server/test/integration/http-server.integration.ts b/packages/http-server/test/integration/http-server.integration.ts index 72c12099b533..cce75085fe97 100644 --- a/packages/http-server/test/integration/http-server.integration.ts +++ b/packages/http-server/test/integration/http-server.integration.ts @@ -2,19 +2,29 @@ // 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 * as path from 'path'; +import * as makeRequest from 'request-promise-native'; +import { + DefaultHttpServer, + RequestListener, + HttpOptions, + HttpServer, + HttpServerOptions, + Http2Options, + ProtocolServerFactory, +} from '../..'; + +import spdy = require('spdy'); describe('HttpServer (integration)', () => { let server: HttpServer | undefined; @@ -22,7 +32,7 @@ describe('HttpServer (integration)', () => { afterEach(stopServer); itSkippedOnTravis('formats IPv6 url correctly', async () => { - server = new HttpServer(dummyRequestHandler, { + server = givenHttpServer(dummyRequestHandler, { host: '::1', } as HttpOptions); await server.start(); @@ -33,7 +43,7 @@ describe('HttpServer (integration)', () => { it('starts server', async () => { const serverOptions = givenHttpServerConfig(); - server = new HttpServer(dummyRequestHandler, serverOptions); + server = givenHttpServer(dummyRequestHandler, serverOptions); await server.start(); await supertest(server.url) .get('/') @@ -42,7 +52,7 @@ describe('HttpServer (integration)', () => { it('stops server', async () => { const serverOptions = givenHttpServerConfig(); - server = new HttpServer(dummyRequestHandler, serverOptions); + server = givenHttpServer(dummyRequestHandler, serverOptions); await server.start(); await server.stop(); await expect( @@ -53,14 +63,14 @@ describe('HttpServer (integration)', () => { }); it('exports original port', async () => { - server = new HttpServer(dummyRequestHandler, {port: 0}); + server = givenHttpServer(dummyRequestHandler, {port: 0}); expect(server) .to.have.property('port') .which.is.equal(0); }); it('exports reported port', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); await server.start(); expect(server) .to.have.property('port') @@ -69,7 +79,7 @@ describe('HttpServer (integration)', () => { }); it('does not permanently bind to the initial port', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); await server.start(); const port = server.port; await server.stop(); @@ -81,14 +91,14 @@ describe('HttpServer (integration)', () => { }); it('exports original host', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); expect(server) .to.have.property('host') .which.is.equal(undefined); }); it('exports reported host', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); await server.start(); expect(server) .to.have.property('host') @@ -96,7 +106,7 @@ describe('HttpServer (integration)', () => { }); it('exports protocol', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); await server.start(); expect(server) .to.have.property('protocol') @@ -105,7 +115,7 @@ describe('HttpServer (integration)', () => { }); it('exports url', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); await server.start(); expect(server) .to.have.property('url') @@ -114,7 +124,7 @@ describe('HttpServer (integration)', () => { }); it('exports address', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); await server.start(); expect(server) .to.have.property('address') @@ -122,12 +132,12 @@ describe('HttpServer (integration)', () => { }); it('exports server before start', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); expect(server.server).to.be.instanceOf(Server); }); it('resets address when server is stopped', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); await server.start(); expect(server) .to.have.property('address') @@ -137,7 +147,7 @@ describe('HttpServer (integration)', () => { }); it('exports listening', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); await server.start(); expect(server.listening).to.be.true(); await server.stop(); @@ -145,10 +155,10 @@ describe('HttpServer (integration)', () => { }); it('reports error when the server cannot be started', async () => { - server = new HttpServer(dummyRequestHandler); + server = givenHttpServer(dummyRequestHandler); await server.start(); const port = server.port; - const anotherServer = new HttpServer(dummyRequestHandler, { + const anotherServer = givenHttpServer(dummyRequestHandler, { port: port, }); await expect(anotherServer.start()).to.be.rejectedWith(/EADDRINUSE/); @@ -184,18 +194,53 @@ describe('HttpServer (integration)', () => { it('converts host from [::] to [::1] in url', async () => { // Safari on MacOS does not support http://[::]:3000/ - server = new HttpServer(dummyRequestHandler, {host: '::'}); + server = givenHttpServer(dummyRequestHandler, {host: '::'}); await server.start(); expect(server.url).to.equal(`http://[::1]:${server.port}`); }); it('converts host from 0.0.0.0 to 127.0.0.1 in url', async () => { // Windows does not support http://0.0.0.0:3000/ - server = new HttpServer(dummyRequestHandler, {host: '0.0.0.0'}); + server = givenHttpServer(dummyRequestHandler, {host: '0.0.0.0'}); await server.start(); expect(server.url).to.equal(`http://127.0.0.1:${server.port}`); }); + it('supports HTTP/2 protocol with key and certificate files', async () => { + // For some reason, spdy fails with Node 11 + if (+process.versions.node.split('.')[0] > 10) return; + const serverOptions: Http2Options = Object.assign( + { + // TypeScript infers `string` as the type for `protocol` and emits + // a compilation error if we don't cast it as `http2` + 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 spdy.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, @@ -208,6 +253,13 @@ describe('HttpServer (integration)', () => { await server.stop(); } + function givenHttpServer( + requestListener: RequestListener, + serverOptions?: HttpServerOptions, + ) { + return new DefaultHttpServer(requestListener, serverOptions); + } + function givenHttpsServer({ usePfx, host, @@ -227,6 +279,35 @@ describe('HttpServer (integration)', () => { options.key = fs.readFileSync(keyPath); options.cert = fs.readFileSync(certPath); } - return new HttpServer(dummyRequestHandler, options); + return new DefaultHttpServer(dummyRequestHandler, options); + } + + class Http2ProtocolServerFactory implements ProtocolServerFactory { + supports(protocol: string, serverOptions: HttpServerOptions) { + return protocol === 'http2' || serverOptions.hasOwnProperty('spdy'); + } + + createServer( + protocol: string, + requestListener: RequestListener, + serverOptions: HttpServerOptions, + ) { + const http2Server = spdy.createServer( + serverOptions as spdy.ServerOptions, + requestListener, + ); + return {server: http2Server, urlScheme: 'https'}; + } + } + + 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 DefaultHttpServer(dummyRequestHandler, options, [ + new Http2ProtocolServerFactory(), + ]); } }); diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts index 1319b350d07a..33dc68b30881 100644 --- a/packages/rest/src/keys.ts +++ b/packages/rest/src/keys.ts @@ -3,16 +3,19 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {CoreBindings} from '@loopback/core'; import {BindingKey, Context} from '@loopback/context'; - +import {CoreBindings} from '@loopback/core'; +import {HttpProtocol, HttpServer} from '@loopback/http-server'; +import * as https from 'https'; /** * See https://github.com/Microsoft/TypeScript/issues/26985 */ // import {OpenApiSpec} from '@loopback/openapi-v3-types'; import {OpenAPIObject as OpenApiSpec} from 'openapi3-ts'; - +import {ErrorWriterOptions} from 'strong-error-handler'; +import {BodyParser, RequestBodyParser} from './body-parsers'; import {HttpHandler} from './http-handler'; +import {RestRouter} from './router'; import {SequenceHandler} from './sequence'; import { BindElement, @@ -20,20 +23,14 @@ import { GetFromContext, InvokeMethod, LogError, - Request, - Response, ParseParams, Reject, - Send, + Request, RequestBodyParserOptions, + Response, + Send, } from './types'; -import {HttpProtocol} from '@loopback/http-server'; -import * as https from 'https'; -import {ErrorWriterOptions} from 'strong-error-handler'; -import {RestRouter} from './router'; -import {RequestBodyParser, BodyParser} from './body-parsers'; - /** * RestServer-specific bindings */ @@ -86,6 +83,11 @@ export namespace RestBindings { 'rest.errorWriterOptions', ); + /** + * Binding key for http server factory + */ + export const HTTP_SERVER = BindingKey.create('rest.httpServer'); + /** * Binding key for request body parser options */ diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 8c389c5aa3df..556ddac90d7e 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -12,7 +12,11 @@ import { BindingAddress, } from '@loopback/context'; import {Application, CoreBindings, Server} from '@loopback/core'; -import {HttpServer, HttpServerOptions} from '@loopback/http-server'; +import { + HttpServer, + HttpServerOptions, + DefaultHttpServer, +} from '@loopback/http-server'; import {getControllerSpec} from '@loopback/openapi-v3'; import { OpenApiSpec, @@ -761,7 +765,12 @@ export class RestServer extends Context implements Server, HttpServerLike { if (protocol === 'https') Object.assign(serverOptions, httpsOptions); Object.assign(serverOptions, {port, host, protocol}); - this._httpServer = new HttpServer(this.requestHandler, serverOptions); + const httpServer: HttpServer = + (await this.get(RestBindings.HTTP_SERVER, { + optional: true, + })) || new DefaultHttpServer(this.requestHandler, serverOptions); + + this._httpServer = httpServer; await this._httpServer.start(); 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};