From 1f89ee5aaf9ff77da44c1a3204a3e8a5d444ee43 Mon Sep 17 00:00:00 2001 From: Hage Yaapa Date: Tue, 22 May 2018 22:43:55 +0530 Subject: [PATCH] feat: add http-server package * Decouple the creation of HTTP/HTTPS server from the `rest` package. * Create new package (`http-server`) to handle the creation of HTTP/HTTPS server. --- packages/http-server/.npmrc | 1 + packages/http-server/README.md | 57 ++++++++ packages/http-server/index.d.ts | 1 + packages/http-server/index.js | 6 + packages/http-server/index.ts | 6 + packages/http-server/package.json | 50 +++++++ packages/http-server/src/http-server.ts | 119 +++++++++++++++++ packages/http-server/src/index.ts | 1 + .../integration/http-server.integration.ts | 125 ++++++++++++++++++ packages/http-server/tsconfig.build.json | 8 ++ packages/rest/package.json | 1 + packages/rest/src/keys.ts | 8 ++ packages/rest/src/rest.server.ts | 49 +++---- .../integration/rest.server.integration.ts | 8 ++ 14 files changed, 411 insertions(+), 29 deletions(-) create mode 100644 packages/http-server/.npmrc create mode 100644 packages/http-server/README.md create mode 100644 packages/http-server/index.d.ts create mode 100644 packages/http-server/index.js create mode 100644 packages/http-server/index.ts create mode 100644 packages/http-server/package.json create mode 100644 packages/http-server/src/http-server.ts create mode 100644 packages/http-server/src/index.ts create mode 100644 packages/http-server/test/integration/http-server.integration.ts create mode 100644 packages/http-server/tsconfig.build.json diff --git a/packages/http-server/.npmrc b/packages/http-server/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/http-server/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/http-server/README.md b/packages/http-server/README.md new file mode 100644 index 000000000000..e25ddb44ab10 --- /dev/null +++ b/packages/http-server/README.md @@ -0,0 +1,57 @@ +# @loopback/http-server + +This package implements the HTTP / HTTPS server endpoint for LoopBack 4 apps. + +## Overview + +This is an internal package used by LoopBack 4 for creating HTTP / HTTPS server. + +## Installation + +To use this package, you'll need to install `@loopback/http-server`. + +```sh +npm i @loopback/http-server +``` + +## Usage + +`@loopback/http-server` should be instantiated with a request handler function, and an HTTP / HTTPS options object. + +```js +const httpServer = new HttpServer((req, res) => { res.end('Hello world')}, {port: 3000, host: ''}); +``` + +Instance methods of `HttpServer`. + +| Method | Description | +| ------- | -------------------- | +| `start()` | Starts the server | +| `stop()` | Stops the server | + +Instance properties of `HttpServer`. + +| Property | Description | +| ----------- | ---------------------- | +| `address` | Address details | +| `host` | host of the server | +| `port` | port of the server | +| `protocol` | protocol of the server | +| `url` | url the server | + +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/wiki/Contributing#guidelines) +- [Join the team](https://github.com/strongloop/loopback-next/issues/110) + +## Tests + +Run `npm test` from the root folder. + +## Contributors + +See [all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/packages/http-server/index.d.ts b/packages/http-server/index.d.ts new file mode 100644 index 000000000000..b13ff95b850f --- /dev/null +++ b/packages/http-server/index.d.ts @@ -0,0 +1 @@ +export * from './dist8'; diff --git a/packages/http-server/index.js b/packages/http-server/index.js new file mode 100644 index 000000000000..b46af17f12e9 --- /dev/null +++ b/packages/http-server/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. 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 + +module.exports = require('@loopback/dist-util').loadDist(__dirname); diff --git a/packages/http-server/index.ts b/packages/http-server/index.ts new file mode 100644 index 000000000000..7c630f3a5b05 --- /dev/null +++ b/packages/http-server/index.ts @@ -0,0 +1,6 @@ +// 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 './src'; diff --git a/packages/http-server/package.json b/packages/http-server/package.json new file mode 100644 index 000000000000..8def85b8b2fc --- /dev/null +++ b/packages/http-server/package.json @@ -0,0 +1,50 @@ +{ + "name": "@loopback/http-server", + "version": "0.0.1", + "description": "", + "engines": { + "node": ">=8" + }, + "scripts": { + "acceptance": "lb-mocha \"DIST/test/acceptance/**/*.js\"", + "build": "npm run build:dist8 && npm run build:dist10", + "build:apidocs": "lb-apidocs", + "build:current": "lb-tsc", + "build:dist8": "lb-tsc es2017", + "build:dist10": "lb-tsc es2018", + "clean": "lb-clean loopback-http-server*.tgz dist* package api-docs", + "pretest": "npm run build:current", + "integration": "lb-mocha \"DIST/test/integration/**/*.js\"", + "test": "lb-mocha \"DIST/test/unit/**/*.js\" \"DIST/test/integration/**/*.js\" \"DIST/test/acceptance/**/*.js\"", + "unit": "lb-mocha \"DIST/test/unit/**/*.js\"", + "verify": "npm pack && tar xf loopback-http-server*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "dependencies": { + "@loopback/dist-util": "^0.3.1", + "p-event": "^2.0.0" + }, + "devDependencies": { + "@loopback/build": "^0.6.5", + "@loopback/core": "^0.8.4", + "@loopback/testlab": "^0.10.4", + "@types/node": "^10.1.2", + "@types/p-event": "^1.3.0", + "@types/request-promise-native": "^1.0.14", + "request-promise-native": "^1.0.5" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist*/src", + "dist*/index*", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} diff --git a/packages/http-server/src/http-server.ts b/packages/http-server/src/http-server.ts new file mode 100644 index 000000000000..11b5a31d9a16 --- /dev/null +++ b/packages/http-server/src/http-server.ts @@ -0,0 +1,119 @@ +// 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 + +import {createServer, Server, ServerRequest, ServerResponse} from 'http'; +import {AddressInfo} from 'net'; +import * as pEvent from 'p-event'; + +export type HttpRequestListener = ( + req: ServerRequest, + res: ServerResponse, +) => void; + +/** + * Object for specifyig the HTTP / HTTPS server options + */ +export type HttpServerOptions = { + port?: number; + host?: string; + protocol?: HttpProtocol; +}; + +export type HttpProtocol = 'http' | 'https'; // Will be extended to `http2` in the future + +/** + * HTTP / HTTPS server used by LoopBack's RestServer + * + * @export + * @class HttpServer + */ +export class HttpServer { + private _port: number; + private _host?: string; + private _started: Boolean; + private _protocol: HttpProtocol; + private _address: AddressInfo; + private httpRequestListener: HttpRequestListener; + private httpServer: Server; + + /** + * @param httpServerOptions + * @param httpRequestListener + */ + constructor( + httpRequestListener: HttpRequestListener, + httpServerOptions?: HttpServerOptions, + ) { + this.httpRequestListener = httpRequestListener; + if (!httpServerOptions) httpServerOptions = {}; + this._port = httpServerOptions.port || 0; + this._host = httpServerOptions.host || undefined; + this._protocol = httpServerOptions.protocol || 'http'; + } + + /** + * Starts the HTTP / HTTPS server + */ + public async start() { + this.httpServer = createServer(this.httpRequestListener); + this.httpServer.listen(this._port, this._host); + await pEvent(this.httpServer, 'listening'); + this._started = true; + this._address = this.httpServer.address() as AddressInfo; + } + + /** + * Stops the HTTP / HTTPS server + */ + public async stop() { + if (this.httpServer) { + this.httpServer.close(); + await pEvent(this.httpServer, 'close'); + this._started = false; + } + } + + /** + * Protocol of the HTTP / HTTPS server + */ + public get protocol(): HttpProtocol { + return this._protocol; + } + + /** + * Port number of the HTTP / HTTPS server + */ + public get port(): number { + return (this._address && this._address.port) || this._port; + } + + /** + * Host of the HTTP / HTTPS server + */ + public get host(): string | undefined { + return (this._address && this._address.address) || this._host; + } + + /** + * URL of the HTTP / HTTPS server + */ + public get url(): string { + return `${this._protocol}://${this.host}:${this.port}`; + } + + /** + * State of the HTTP / HTTPS server + */ + public get started(): Boolean { + return this._started; + } + + /** + * Address of the HTTP / HTTPS server + */ + public get address(): AddressInfo { + return this._address; + } +} diff --git a/packages/http-server/src/index.ts b/packages/http-server/src/index.ts new file mode 100644 index 000000000000..3c164c19d8a5 --- /dev/null +++ b/packages/http-server/src/index.ts @@ -0,0 +1 @@ +export * from './http-server'; diff --git a/packages/http-server/test/integration/http-server.integration.ts b/packages/http-server/test/integration/http-server.integration.ts new file mode 100644 index 000000000000..998e4f4cab72 --- /dev/null +++ b/packages/http-server/test/integration/http-server.integration.ts @@ -0,0 +1,125 @@ +// 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 +import {HttpServer} from '../../'; +import {supertest, expect} from '@loopback/testlab'; +import * as makeRequest from 'request-promise-native'; +import {ServerRequest, ServerResponse} from 'http'; + +describe('HttpServer (integration)', () => { + it('starts server', async () => { + const server = new HttpServer(dummyRequestHandler); + await server.start(); + supertest(server.url) + .get('/') + .expect(200); + await server.stop(); + }); + + it('stops server', async () => { + const server = new HttpServer(dummyRequestHandler); + await server.start(); + await server.stop(); + await expect( + makeRequest({ + uri: server.url, + }), + ).to.be.rejectedWith(/ECONNREFUSED/); + }); + + it('exports original port', async () => { + const server = new HttpServer(dummyRequestHandler, {port: 0}); + expect(server) + .to.have.property('port') + .which.is.equal(0); + }); + + it('exports reported port', async () => { + const server = new HttpServer(dummyRequestHandler); + await server.start(); + expect(server) + .to.have.property('port') + .which.is.a.Number() + .which.is.greaterThan(0); + await server.stop(); + }); + + it('does not permanently bind to the initial port', async () => { + const server = new HttpServer(dummyRequestHandler); + await server.start(); + const port = server.port; + await server.stop(); + await server.start(); + expect(server) + .to.have.property('port') + .which.is.a.Number() + .which.is.not.equal(port); + await server.stop(); + }); + + it('exports original host', async () => { + const server = new HttpServer(dummyRequestHandler); + expect(server) + .to.have.property('host') + .which.is.equal(undefined); + }); + + it('exports reported host', async () => { + const server = new HttpServer(dummyRequestHandler); + await server.start(); + expect(server) + .to.have.property('host') + .which.is.a.String(); + await server.stop(); + }); + + it('exports protocol', async () => { + const server = new HttpServer(dummyRequestHandler); + await server.start(); + expect(server) + .to.have.property('protocol') + .which.is.a.String() + .match(/http|https/); + await server.stop(); + }); + + it('exports url', async () => { + const server = new HttpServer(dummyRequestHandler); + await server.start(); + expect(server) + .to.have.property('url') + .which.is.a.String() + .match(/http|https\:\/\//); + await server.stop(); + }); + + it('exports address', async () => { + const server = new HttpServer(dummyRequestHandler); + await server.start(); + expect(server) + .to.have.property('address') + .which.is.an.Object(); + await server.stop(); + }); + + it('exports started', async () => { + const server = new HttpServer(dummyRequestHandler); + await server.start(); + expect(server.started).to.be.true(); + await server.stop(); + expect(server.started).to.be.false(); + }); + + it('start() returns a rejected promise', async () => { + const serverA = new HttpServer(dummyRequestHandler); + await serverA.start(); + const port = serverA.port; + const serverB = new HttpServer(dummyRequestHandler, {port: port}); + expect(serverB.start()).to.be.rejectedWith(/EADDRINUSE/); + }); + + function dummyRequestHandler(req: ServerRequest, res: ServerResponse): void { + res.end(); + } +}); diff --git a/packages/http-server/tsconfig.build.json b/packages/http-server/tsconfig.build.json new file mode 100644 index 000000000000..3ffcd508d23e --- /dev/null +++ b/packages/http-server/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["index.ts", "src", "test"] +} diff --git a/packages/rest/package.json b/packages/rest/package.json index 43d9ac3e097d..0f70955fa46d 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -26,6 +26,7 @@ "@loopback/context": "^0.11.2", "@loopback/core": "^0.8.4", "@loopback/openapi-v3": "^0.10.5", + "@loopback/http-server": "^0.0.1", "@loopback/openapi-v3-types": "^0.7.4", "@types/cors": "^2.8.3", "@types/express": "^4.11.1", diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts index 8ebc0c8d7033..3d6159eb987b 100644 --- a/packages/rest/src/keys.ts +++ b/packages/rest/src/keys.ts @@ -42,6 +42,14 @@ export namespace RestBindings { * Binding key for setting and injecting the port number of RestServer */ export const PORT = BindingKey.create('rest.port'); + /** + * Binding key for setting and injecting the URL of RestServer + */ + export const URL = BindingKey.create('rest.url'); + /** + * Binding key for setting and injecting the protocol of RestServer + */ + export const PROTOCOL = BindingKey.create<'http' | 'https'>('rest.protocol'); /** * Internal binding key for http-handler */ diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index cbdb682712a5..99ed6b1caf63 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -16,8 +16,8 @@ import { createControllerFactoryForBinding, } from './router/routing-table'; import {OpenApiSpec, OperationObject} from '@loopback/openapi-v3-types'; -import {ServerRequest, ServerResponse, createServer} from 'http'; -import * as Http from 'http'; +import {ServerRequest, ServerResponse} from 'http'; +import {HttpServer} from '@loopback/http-server'; import * as cors from 'cors'; import {Application, CoreBindings, Server} from '@loopback/core'; import {getControllerSpec} from '@loopback/openapi-v3'; @@ -35,7 +35,6 @@ import { import {RestBindings} from './keys'; import {RequestContext} from './request-context'; import * as express from 'express'; -import {AddressInfo} from 'net'; export type HttpRequestListener = ( req: ServerRequest, @@ -127,7 +126,7 @@ export class RestServer extends Context implements Server, HttpServerLike { this._setupHandlerIfNeeded(); return this._httpHandler; } - protected _httpServer: Http.Server; + protected _httpServer: HttpServer; protected _expressApp: express.Application; @@ -159,6 +158,7 @@ export class RestServer extends Context implements Server, HttpServerLike { } this.bind(RestBindings.PORT).to(options.port); this.bind(RestBindings.HOST).to(options.host); + this.bind(RestBindings.PROTOCOL).to(options.protocol || 'http'); if (options.sequence) { this.sequence(options.sequence); @@ -573,23 +573,22 @@ export class RestServer extends Context implements Server, HttpServerLike { // of API spec, controllers and routes at startup time. this._setupHandlerIfNeeded(); - const httpPort = await this.get(RestBindings.PORT); - const httpHost = await this.get(RestBindings.HOST); - this._httpServer = createServer(this.requestHandler); - const httpServer = this._httpServer; - - // TODO(bajtos) support httpHostname too - // See https://github.com/strongloop/loopback-next/issues/434 - httpServer.listen(httpPort, httpHost); + const port = await this.get(RestBindings.PORT); + const host = await this.get(RestBindings.HOST); + const protocol = await this.get<'http' | 'https' | undefined>( + RestBindings.PROTOCOL, + ); - return new Promise((resolve, reject) => { - httpServer.once('listening', () => { - const address = httpServer.address() as AddressInfo; - this.bind(RestBindings.PORT).to(address.port); - resolve(); - }); - httpServer.once('error', reject); + this._httpServer = new HttpServer(this.requestHandler, { + port: port, + host: host, + protocol: protocol || 'http', }); + + await this._httpServer.start(); + this.bind(RestBindings.PORT).to(this._httpServer.port); + this.bind(RestBindings.HOST).to(this._httpServer.host); + this.bind(RestBindings.URL).to(this._httpServer.url); } /** @@ -600,16 +599,7 @@ export class RestServer extends Context implements Server, HttpServerLike { */ async stop() { // Kill the server instance. - const server = this._httpServer; - return new Promise((resolve, reject) => { - server.close((err: Error) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); + await this._httpServer.stop(); } protected _onUnhandledError(req: Request, res: Response, err: Error) { @@ -639,4 +629,5 @@ export interface RestServerConfig { cors?: cors.CorsOptions; apiExplorerUrl?: string; sequence?: Constructor; + protocol?: 'http' | 'https'; } diff --git a/packages/rest/test/integration/rest.server.integration.ts b/packages/rest/test/integration/rest.server.integration.ts index de5029d8f295..e2b7443f88ea 100644 --- a/packages/rest/test/integration/rest.server.integration.ts +++ b/packages/rest/test/integration/rest.server.integration.ts @@ -16,6 +16,14 @@ describe('RestServer (integration)', () => { await server.stop(); }); + it('honors port binding after instantiation', async () => { + const server = await givenAServer({rest: {port: 80}}); + await server.bind(RestBindings.PORT).to(0); + await server.start(); + expect(server.getSync(RestBindings.PORT)).to.not.equal(80); + await server.stop(); + }); + it('responds with 500 when Sequence fails with unhandled error', async () => { const server = await givenAServer({rest: {port: 0}}); server.handler((context, sequence) => {